[{"content":"缺口：戰略協調有了，戰術執行還在手動驅動 上一篇講了一份 CUTOVER.md 怎麼當跨 session、跨 repo 的控制平面 — 用 8 個 pattern 解決「AI agent 有遺忘症、任務卻沒結束」的協調問題。那套機制解的是戰略層：哪些高階決策被做過、哪個方案被否決、誰改完了等誰配合。\n但它有個明顯缺口。Cutover doc 追蹤的是「高階任務」這個粒度 — 例如「後端 dual-write regression 要修」是一個 row。可是這個 row 底下，真正的執行還是我一個 session、一個 session 手動驅動：開 session、貼 pickup prompt、看它跑、它卡住我再 nudge、跑完我再回來 push doc。\n換句話說，戰略層自動化了（doc 自己會被各 session pickup），但每個高階任務內部的戰術 loop — 寫測試、跑、看結果、修、再跑 — 還是人在當 orchestrator。\nAnthropic 自家在 Building effective agents 這篇 engineering 文章裡定義過一個叫 orchestrator-workers（orchestrator–工人 pattern — 一個中央 LLM 動態拆解任務、派發給 worker LLM、再彙總結果）的工作流：原文寫「a central LLM dynamically breaks down tasks, delegates them to worker LLMs, and synthesizes their results」。配上 Claude Code 的 subagents（子代理 — 由主 agent spawn 的獨立 agent，每個都有自己 isolated context window）跟 headless mode（非互動模式 — 用 claude -p 把 prompt 餵進去、跑完吐結果，不開互動對話），就能補上 cutover doc 的戰術缺口 — 也就是「單一高階任務底下，怎麼讓 AI 自己跑數小時的 autonomous loop（自主迴圈 — 不需要人在中間 nudge，loop 自己 plan / execute / evaluate 直到條件達成）而不撞牆」。\n把這兩件事疊起來，會長出一個乾淨的兩層架構。\n戰略層回顧：cutover doc 是 long-term memory 先快速回顧上一篇的核心：cutover doc 是一份放在獨立 metadata repo 的 append-only 文件，扮演跨 session 的 out-of-band control plane（帶外控制平面）。它的關鍵特質是「為遺忘症而設計」：\nAppend-only audit trail — 否決過的方案不刪，永遠攤在桌上，避免下個 session 自信地重新提案。 High-density activity log — 每一列 Note 自我完備（root cause、failing code path、verification、cleanup），下個 session 不必翻 commit 就能還原全部上下文。 Pickup prompt — 給下個 AI agent 開場讀的 step-by-step prompt，不是給人看的 README。 它的時間尺度是天到月。一份 doc 累積 30 天、十幾個 session 的協調歷史。它記的是「為什麼這樣決定」這類元決策 — 這是 raw git history 永遠翻不出來的東西。\n但 cutover doc 從不關心「某個高階任務內部，AI 跑了幾輪 loop 才把測試弄綠」。那是太細的粒度，寫進 append-only doc 只會稀釋訊號。這正是戰術層該接手的地方。\n戰術層：orchestrator + claude headless 為什麼不撞牆 問題的核心是：如果讓單一 agent 在同一個 context window 裡跑 planning → execution → evaluation 一輪又一輪，context 會越長、準確度越低 — 也就是所謂的 context wall（上下文牆 — 同一 context window 越長，模型準確度單調下降的現象）。最糟的情境是它在 evaluation 階段幻覺、誤判「任務完成」其實沒完成。\nAnthropic subagents 文件 對此給的設計哲學是：subagent「help manage context by using isolated context windows with only relevant information sent back to the orchestrator」— 用 isolated context window（隔離上下文視窗 — 子 agent 跑自己一份乾淨 context，跑完只回傳精簡結論）來防止主 agent 累積雜訊。配合 Agent SDK 的 headless 模式，對應的解法就是把角色拆兩個：\nOrchestrator — 只負責 planning 跟 evaluation，不親自執行。因為它不碰繁重的執行細節，它的 context window 能長期維持在低佔用。這正是 orchestrator-workers pattern 的 orchestrator 角色。 claude headless — 每一輪 iteration 由 orchestrator 觸發一個全新的 headless session 去執行。每個 headless 都是乾淨的 context window，跑完即丟（這正是 worker / subagent 角色）。 關鍵在最後那一步：headless session 可以自己再 spawn subagent、自己消化大量細節，只把精簡結論回報給 orchestrator。所以繁重的 token 消耗都發生在「跑完即丟」的 headless 裡，orchestrator 的 context 始終乾淨。這就是為什麼這套能撐數小時到數天的 autonomous 執行而不撞牆。Anthropic 在 How we built our multi-agent research system 那篇文章裡也分享了類似結構 — 一個 lead agent 規劃方向，多個 subagent 並行去 retrieve（檢索 — 從不同來源找資料），各跑各的 context、最後彙整。\n實作上常見的做法是把它包成 Claude Code 的 skill：一個 super orchestrator 負責調度，每一輪觸發對應的 skill — 例如 super QA（用 BFS（廣度優先搜尋 — 一層一層往外擴的走訪法）走訪所有頁面、寫 Playwright（瀏覽器端對端測試框架）e2e、找 bug）、super build（用 TDD（測試驅動開發 — 先寫測試再寫實作）修 bug）。loop 條件就是「沒有更多 bug 可修、沒有更多 feature 要測」。\n而要讓這個 loop 知道「現在做到哪、還有什麼沒做」，他需要 state。\n接口：state 怎麼分層 State 可以存在 markdown file 裡，但用 GitHub Projects（GitHub 內建的看板工具 — 跨 repo、跨 issue 的任務看板）當看板更順手：queue、testing、done、bug、flaky（不穩定 — 有時過有時不過的測試）、skip 幾個欄位，靠 Claude Code 內建的 GitHub CLI 直接讀寫。super QA 用 BFS 把待測 feature 一個個推進 queue，測過的進 visited，壞的進 bug 欄。\n這裡有個值得注意的對位關係。GitHub Project 在這個架構下扮演的是戰術 state（tactical state — 易變、單一 loop 壽命的工作佇列） — 它的生命週期就跟這一輪 orchestrator loop 一樣長，是一個高速、易變、用完即清的工作佇列。它對應的概念是 CPU 的 L1 cache（L1 快取 — CPU 內最快、最小、最易變的記憶層）或人的 working memory（工作記憶 — 大腦同時操作幾個項目的短期暫存）。\n而我的 cutover doc 是戰略 state — append-only、永不刪、跨月保存。它對應的是 main memory / disk，或人的 long-term memory。\n兩層之間靠兩個方向的接口連起來：\n向下（doc → orchestrator）：cutover doc 的 pickup section 本來就是寫給 AI 開場讀的 step-by-step prompt。現在它不再餵給「我手動開的 session」，而是直接當成 orchestrator 的高階 mandate — orchestrator 讀完戰略 context，知道「這一輪要解 B7 dual-write regression」，然後自己派 headless 去跑。\n向上（orchestrator → doc）：當戰術 loop 終止（condition 達成），orchestrator 把整輪的結果壓縮成一列 high-density activity log，append 回 cutover doc。中間跑了 12 輪、開了 30 個 headless session 的雜訊全部丟棄，只留下自我完備的結論：root cause、最終 fix、verification proof、commit hash。\n這正好呼應上一篇的設計哲學 — 戰術層產生海量低密度過程，戰略層只收高密度結論。promote 這個動作就是兩層之間的壓縮閥。\n一次完整運作長什麼樣 把兩層接起來後，一個高階任務的生命週期大致是這樣：\n我這邊的真實情境：cutover doc 上有一條 Open Q「dual-write 沒寫 link 表」。過去我得自己開 session 貼 pickup、盯著跑。接上 orchestrator 之後，這條 Open Q 變成 orchestrator 的條件式任務 — 它派 headless 反覆「寫 regression 測試 → 跑 → 看哪條 link row 沒被寫入 → 修 resolver → 再跑」，直到 5 條測試全綠。整輪跑完，orchestrator 才回來把 Q 標 Closed、附上 commit fc914a8、壓一列 Note 進 activity log。\n我從「loop 的驅動者」退位成「loop 的設計者」 — 我定義條件跟 pickup prompt，執行交給戰術層，結果由戰略層歸檔。\n把自己升級成 loop 的設計者 — 完整 how-to 「升級成 loop 的設計者」聽起來抽象，具體要做的事其實只有四步：寫 pickup mandate（任務委派 — 給 AI agent 的明確指令格式）、寫 orchestrator script、定義 termination condition（終止條件 — loop 自我判定該停的 boolean 表達式）、定義 promote rule（晉升規則 — 戰術結論怎麼壓縮回戰略 doc）。\nStep 1：把 cutover doc 的 Open Q 改寫成 mandate cutover doc 上的 Open Q 平常是給人讀的，長這樣：\n### Q B7: dual-write regression — link table 沒被寫入 - Status: Open - Owner: backend - Severity: high - 觸發條件: listing detail 顯示 0 個 amenity，但 admin 後台有勾 要餵給 orchestrator 之前，要在這條 Q 底下接一段 mandate 區塊：\n### Pickup mandate (machine target) **Task condition (terminate when ALL true):** - `pytest tests/regression/dual_write_test.py` exit code = 0 - 至少一條測試覆蓋「link table 沒被寫入」這個 root cause - 既有 116 條 integration 測試全綠（`pytest tests/integration/`） **Stop condition (terminate when ANY true):** - Task condition 達成 - iteration 數 ≥ 20 - 連續 3 輪 evaluation 評為「no progress」 **Output contract (orchestrator 收到的 JSON 必須含):** - `final_commit`：完成的 commit hash - `root_cause`：一句話 root cause - `verification`：測試輸出最後三行 - `condition_met`：boolean 關鍵設計：condition 必須機器可驗。「測試全綠」可驗，「修得不錯」不可驗。把模糊的人話翻譯成 boolean 表達式，是 loop 設計者最關鍵的工作。\nStep 2：orchestrator script — 最小可運行版本 orchestrator 不必複雜，bash + jq（命令列 JSON 處理工具 — 從 JSON 抽欄位、轉換、過濾）就夠：\n#!/usr/bin/env bash set -euo pipefail MANDATE_FILE=\u0026#34;cutover/B7-dual-write-regression.mandate.md\u0026#34; STATE_FILE=\u0026#34;.orchestrator/queue.json\u0026#34; LOG_FILE=\u0026#34;.orchestrator/log.jsonl\u0026#34; MAX_ITERATIONS=20 NO_PROGRESS_LIMIT=3 iteration=0 no_progress_count=0 while [ $iteration -lt $MAX_ITERATIONS ]; do iteration=$((iteration + 1)) echo \u0026#34;=== iteration $iteration ===\u0026#34; # 1) spawn 全新 headless session（每輪都是乾淨 context window） result=$(timeout 600 claude -p \\ --output-format json \\ --allowedTools \u0026#34;Bash,Read,Edit,Write,Grep,Glob\u0026#34; \\ \u0026#34;$(cat \u0026#34;$MANDATE_FILE\u0026#34;) 當前 state: $(cat \u0026#34;$STATE_FILE\u0026#34;) 請以 JSON 回覆，包含 condition_met / progress / state_delta / final_commit / root_cause / verification 欄位。\u0026#34;) echo \u0026#34;$result\u0026#34; | jq -c \u0026#34;. + {iteration: $iteration}\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; # 2) evaluate：condition 達成？ if echo \u0026#34;$result\u0026#34; | jq -e \u0026#39;.condition_met == true\u0026#39; \u0026gt; /dev/null; then echo \u0026#34;✓ condition met at iteration $iteration\u0026#34; break fi # 3) progress check if echo \u0026#34;$result\u0026#34; | jq -e \u0026#39;.progress == \u0026#34;none\u0026#34;\u0026#39; \u0026gt; /dev/null; then no_progress_count=$((no_progress_count + 1)) [ $no_progress_count -ge $NO_PROGRESS_LIMIT ] \u0026amp;\u0026amp; { echo \u0026#34;✗ no progress, abort\u0026#34;; exit 1; } else no_progress_count=0 fi # 4) 更新 state（state delta 是這一輪 headless 算出的「下一輪該知道什麼」） echo \u0026#34;$result\u0026#34; | jq \u0026#39;.state_delta\u0026#39; \u0026gt; \u0026#34;$STATE_FILE\u0026#34; done # 5) promote 回 cutover doc（壓縮成一列 Note） final_commit=$(echo \u0026#34;$result\u0026#34; | jq -r \u0026#39;.final_commit\u0026#39;) root_cause=$(echo \u0026#34;$result\u0026#34; | jq -r \u0026#39;.root_cause\u0026#39;) verification=$(echo \u0026#34;$result\u0026#34; | jq -r \u0026#39;.verification\u0026#39;) cat \u0026gt;\u0026gt; ../metadata-repo/CUTOVER.md \u0026lt;\u0026lt;EOF ### Note $(date +%Y-%m-%d) — Q B7 closed - **Root cause:** $root_cause - **Fix:** commit \\`$final_commit\\` - **Verification:** $verification - **Iterations consumed:** $iteration EOF git -C ../metadata-repo add CUTOVER.md git -C ../metadata-repo commit -m \u0026#34;close: Q B7 dual-write regression\u0026#34; 每一輪 claude -p 都是全新 context window。它讀完 mandate + 當前 state，自己決定要 spawn 什麼 subagent、跑什麼指令，最後回一個結構化 JSON。--output-format json 是 Agent SDK headless docs 列出的官方旗標 — 它讓 stdout 變成可程式化處理的 JSON，包含 total_cost_usd、各 model 用量明細，方便用 jq 抽欄位。\nStep 3：把 condition 寫成 boolean 表達式 最常見的失敗是 condition 寫得太鬆或太模糊。三個好 condition 的特徵：\n特徵 好的範例 壞的範例 機器可驗（machine-verifiable） pytest ... \u0026amp;\u0026amp; [ $? -eq 0 ] 「fix 看起來對」 有界（bounded） 「5 條測試全綠」 「測試夠多就好」 冪等（idempotent — 跑幾次結果一樣） 「pure unit test，無外部依賴」 「跑完之後 DB 是某狀態」 寫不出機器可驗的 condition，就是這個高階任務還不適合下放到自主迴圈 — 留給人手動更安全。\nStep 4：promote 規範 orchestrator 跑完不是直接 push code，而是按一個固定模板把整輪結果壓成一列高密度 Note 加進 cutover doc 的 activity log：\n### Note 2026-05-22 14:30 — Q B7 closed **Root cause:** GraphQL resolver 的 nested write 沒呼叫 link table 的 setter — listing.amenities 雖然 setter 有跑，但 link table 的 amenityId 對應永遠是空。 **Fix:** commit `fc914a8` — 在 `resolveAmenities` middleware 末尾加 `await ctx.entityManager.flush()`，強制把 pending writes 沖到 DB。 **Verification:** 5/5 regression 測試通過，既有 116 條 integration 測試全綠。 **Iterations consumed:** 12 orchestrator iterations / 47 sub-headless spawns / 約 800k token 整輪 12 輪 orchestrator iteration、47 個 headless session、800k token 的中間過程全部不寫進 doc。doc 只留下一列自我完備的結論。中間過程留在 .orchestrator/log.jsonl（戰術 log — 跟戰術 state 一樣壽命，跑完即清）。\n個案實錄：dual-write regression 全 12 輪 把上一節抽象的 loop 用實際案例展開。起點是 cutover doc 第 17 列：\n| 2026-05-15 | B7 | dual-write 沒寫 link 表 | Open | high | backend | 我貼進 pickup mandate 那一刻就退場了。下面是 orchestrator 跑出來的 trace（從 .orchestrator/log.jsonl 摘要）：\niteration 1–2（探索階段）\niter 1：headless spawn pytest 跑既有 116 條 integration test → 全綠，問題不在 read path iter 2：headless 自己寫一個 hypothesis test，模擬建立 listing 並查 link table → 0 row，root cause confirmed iteration 3–4（定位階段）\niter 3：headless 用 grep + Read 走訪 resolver code，找到嫌疑犯 resolveAmenities iter 4：headless 加 console.log 印 entityManager 內部 state，發現 setter 走的不是 flush path iteration 5–7（修法 A — 失敗路徑）\niter 5：headless 改用 bulkInsert 一次寫入，跑測試 → 4/5 過（剩下一條是 race condition — 並發寫入順序不對） iter 6：headless 加 mutex（互斥鎖 — 同時只允許一個 thread 進入該段 code），跑 → 1/5 過（鎖太狠 deadlock — 兩條 thread 互相等對方 release 永遠卡死） iter 7：orchestrator 對比 iter 5/6 結果，評估「regression vs. progress」 — 標記 A 路徑 progress=none iteration 8–11（修法 B — 成功路徑）\niter 8：headless 切回原 path，改在 resolveAmenities middleware 末尾加 await flush() iter 9：跑 regression → 5/5 過 iter 10：headless 自己 spawn 一個 subagent 跑既有 integration 全集 → 116/116 過 iter 11：headless 再 spawn subagent 跑 chaos test（混沌測試 — 亂序、並發、模擬 partial failure 的 stress test）→ 全通過 iteration 12（收尾）\nheadless 用 git commit -m、跑 git log -1 --format=%H 拿到 commit hash fc914a8 orchestrator 評估「condition met」，break out of loop promote phase：寫一列 Note 進 cutover doc，標 Q B7 為 Closed Context 使用量曲線（用 claude --output-format json 拿到的 total_cost_usd 換算）：\n角色 start mid（iter 6） end（iter 12） orchestrator 4k tokens 13k tokens 18k tokens 單一 headless 0 60k tokens（單輪 peak） 0（跑完即丟） 累積消耗 0 ~350k ~800k orchestrator 自己的 context 一直維持在 \u0026lt; 20k（健康區），因為每輪結束都只留一行結論。每個 headless 的 60k tokens 跑完即丟，下一輪是乾淨的 0。如果整件事跑在同一個 session 裡，總 context 估計會超過 700k — 早就撞 context wall 而誤判 done。\n五個踩過的雷 這套架構講起來很美，但我踩過五個雷，每個都讓我重新設計過 orchestrator script。\n雷 1：condition 寫得太模糊 → 無限迴圈 第一版 mandate 我寫的 condition 是「測試看起來大致對」。orchestrator 跑了 50 輪 evaluation 永遠不結束，因為 LLM 對「看起來對」的評估每次都不太一樣 — 有時 confident、有時保守。\n修法：強制 condition 必須是 boolean 表達式 — 測試 exit code、檔案 hash、HTTP status code、grep 出來的行數。我現在的 mandate template 第一行就是 必須以 \\exit code` / `file hash` / `row count` 等可機器驗證的指標表達`。\n雷 2：headless session 沉默卡死 claude -p 偶爾會卡在某個 MCP server（Model Context Protocol server — Claude 連外部工具的協議）或卡在 Bash permission prompt，30 分鐘沒回應。orchestrator 傻等就一直空轉，計費還在累積。\n修法：每個 spawn 用 timeout 600 claude -p ... 強制 600 秒上限，超時直接 kill 並計一輪 retry。Step 2 那段 bash script 已經放了這個保險。另外 --allowedTools 明確列出允許的工具白名單，避免它跑到陌生工具被卡住。\n雷 3：subagent 之間共享了 working state 某輪 headless A 改了 file X，下一輪 headless B 自以為 X 沒人動，覆蓋 A 的修改。最後 commit 是錯版本。\n修法：每個 headless spawn 前先 git stash、跑完再 commit；或更乾脆讓 orchestrator 跑在 git worktree（git 工作樹 — 同一 repo 多個 isolated 工作目錄，每個有獨立的 working files）裡，每個 headless 配一個專屬 worktree。Anthropic 的 Claude Code Advanced Patterns webinar 也提到這種隔離方式。\n雷 4：promote 太頻繁 → 戰略 doc 被戰術 log 淹沒 最早我設計 orchestrator「每輪都 append 一筆進 doc」，想要可追蹤性。結果一週下來 doc 多了 300 列 Note，每列幾乎都是「跑了測試」、「retry 了」這種雜訊 — 完全違反高密度原則，下一個 session pickup 時被淹死。\n修法：promote 只在整個 mandate 完成或徹底失敗時發生，中間過程一律寫到 .orchestrator/log.jsonl（戰術 log，跟戰術 state 一樣壽命，跑完即清）。promote 是兩層之間的壓縮閥，絕對不能變漏斗。\n雷 5：orchestrator 自己撞 context wall（諷刺版） 聽起來很諷刺：orchestrator 不就是為了不撞 context wall 設計的嗎？但 orchestrator 跑 50 輪 iteration、每輪即使只留結論也會堆積 — 我跑過一輪 200 個 iteration 的 dataset migration（資料集遷移 — 把舊版 schema 的全部資料轉到新 schema），orchestrator 自己 context 飆到 80k 也開始幻覺，跳過某些 batch。\n修法：orchestrator 每 N 輪（我設 30）自己也spawn 一個 sub-orchestrator，把目前 state 摘要交棒。記憶體階層可以多階 — 跟 CPU 有 L1 / L2 / L3 cache 一樣，AI 工作流也可以有 orchestrator → sub-orchestrator → headless 三層或更多。Anthropic 在 How we built our multi-agent research system 那篇也描述過 lead agent → subagent → tool-use 三層結構。\n跟 prd / ralph / autonomous-loop 的對位 讀者可能會問：這套不是已經有現成工具嗎？簡單對位一下：\n工具 它解什麼 跟本文架構的關係 prd（產品需求文件產生器 — skill 名稱，從一段描述產出規格 JSON） 把模糊需求轉成可讀規格 一次性產出物，不負責執行；可以餵給 orchestrator 當 mandate 起點 ralph（autonomous task runner — skill 名稱，從 prd.json 跑自主迴圈到完成） 從規格直接跑自主迴圈到完成 是 orchestrator + worker 的一種具體實作；本文架構描述更底層的 pattern，ralph 是其中一種應用形式 autonomous-loop / /loop（Claude Code 內建的循環觸發機制） 在單一 session 內按 cadence 自動觸發 仍是單一 context window，撞 context wall 風險還在；適合短任務 / 輪詢，不適合跨小時 autonomous GitHub Actions / cron 按固定時間觸發 不會基於「condition 達成」終止，是 push-based（外部推進）而非 pull-based（內部拉動）loop Anthropic Claude Code auto mode 用 classifier 判斷 action 是否安全自動執行，省掉人工 approve 解的是「permission 自動化」，不是「context 自動化」；可以跟本文架構疊用 — orchestrator 觸發 headless 時開 auto mode 跳過授權 最大的對位差別在於：本文架構強調「兩層分離」。prd / ralph / autonomous-loop / auto mode 都聚焦在「單一 autonomous run 怎麼跑得久或跑得安全」，但都沒處理「跨月、跨團隊、跨 repo 的失憶問題」。cutover doc 接在它們上面，把它們從「能跑」升級成「跑完還記得跑過什麼、為什麼那樣決定」。\n合理的疊法是：\nprd 跟 cutover doc 都屬於戰略層，但 prd 是前向產生（從需求生規格），cutover doc 是後向歸檔（從執行結果壓決策日誌）。兩者其實互補 — 一個產 mandate、一個收 result。\n為什麼一定要分兩層，不能合成一層 直覺上會想：何不讓 orchestrator 直接讀寫 cutover doc 當它的 state 就好，省掉 GitHub Project？\n不行，理由跟「為什麼 L1 cache 不能直接是 disk」一樣：\n更新頻率天差地遠。戰術 queue 一輪 loop 可能改 50 次（推進、標 testing、標 bug、retry）。如果這些全寫進 append-only 的 doc，doc 會在幾小時內爆量到沒人（也沒 AI）讀得動，徹底違反「高密度、自我完備」的初衷。 保存期不同。戰術 state 是用完即丟的；戰略 doc 要跨月保存。把易變資料塞進不可變的日誌，等於拿永久儲存去存暫存資料。 讀者不同。戰術 state 的讀者是 orchestrator 自己（機器迴圈）；戰略 doc 的讀者是「下一個從零開始的 session」。混在一起會讓兩種讀者都讀到一堆對自己無用的雜訊。 分兩層，每層各自最佳化自己的更新頻率、保存期、讀者 — 這是記憶體階層幾十年來反覆驗證的同一招。\nWhen NOT to stack both layers 跟上一篇一樣，過早疊兩層 = 過度設計：\n單輪就能解的任務：如果一個高階任務 headless 跑一兩輪就完成，根本不需要 orchestrator loop，更不需要回填 doc。直接做。 沒有可機器判定的終止條件：orchestrator loop 的命脈是「condition 達成沒」。如果任務無法寫成「測試全綠 / queue 清空」這種機器可驗的條件，戰術層就退化成無限迴圈，不如人來判斷。 任務不跨 session：如果整件事一個下午、單一 session 就收工，戰略層那套 cutover doc 純屬空轉 — 上一篇已經講過這點。 合理的採用順序是：先有跨 session 的痛、長出 cutover doc（戰略層）；之後發現某些高階任務內部的 loop 重複到值得自動化，再往下接 orchestrator（戰術層）。兩層都該是被痛點逼出來的，不是預先設計的。\nClosing：把記憶體階層搬到 AI 工作流 回頭看，這整件事就是把電腦科學最古老的一個概念 — 記憶體階層 — 搬到 AI 工作流上：\n戰略層（cutover doc）= 慢、大、持久的 long-term memory，存的是跨月的決策日誌。 戰術層（orchestrator + headless）= 快、小、易變的 working memory，跑的是單一任務內的自動化 loop。 兩層之間靠 promote（戰術結論壓縮上傳）跟 pickup prompt（戰略任務派發下放）雙向接口連起來。 Anthropic 的 orchestrator-workers pattern 自己解的是「單一 autonomous run 不要撞 context wall」；我的 cutover doc 解的是「跨 session 不要失憶」。它們各自只覆蓋一個時間尺度。疊起來，才第一次有了「從數小時到數月、全程不掉上下文」的完整覆蓋。\n下次你發現某個 cutover doc 上的高階任務，自己一個 session 一個 session 手動驅動到厭世 — 別再當那個 loop 的驅動者。在那條 Open Q 底下接一個 orchestrator，把自己升級成 loop 的設計者就好。\n","permalink":"https://peter-notes.com/posts/strategic-cutover-docs-with-tactical-headless-orchestrators/","summary":"\u003ch2 id=\"缺口戰略協調有了戰術執行還在手動驅動\"\u003e缺口：戰略協調有了，戰術執行還在手動驅動\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"/posts/async-multi-agent-coordination-via-markdown-control-plane/\"\u003e上一篇\u003c/a\u003e講了一份 \u003ccode\u003eCUTOVER.md\u003c/code\u003e 怎麼當跨 session、跨 repo 的控制平面 — 用 8 個 pattern 解決「AI agent 有遺忘症、任務卻沒結束」的協調問題。那套機制解的是\u003cstrong\u003e戰略層\u003c/strong\u003e：哪些高階決策被做過、哪個方案被否決、誰改完了等誰配合。\u003c/p\u003e\n\u003cp\u003e但它有個明顯缺口。Cutover doc 追蹤的是「高階任務」這個粒度 — 例如「後端 dual-write regression 要修」是一個 row。可是這個 row 底下，\u003cstrong\u003e真正的執行還是我一個 session、一個 session 手動驅動\u003c/strong\u003e：開 session、貼 pickup prompt、看它跑、它卡住我再 nudge、跑完我再回來 push doc。\u003c/p\u003e\n\u003cp\u003e換句話說，戰略層自動化了（doc 自己會被各 session pickup），但每個高階任務內部的\u003cstrong\u003e戰術 loop\u003c/strong\u003e — 寫測試、跑、看結果、修、再跑 — 還是人在當 orchestrator。\u003c/p\u003e\n\u003cp\u003eAnthropic 自家在 \u003ca href=\"https://www.anthropic.com/engineering/building-effective-agents\"\u003eBuilding effective agents\u003c/a\u003e 這篇 engineering 文章裡定義過一個叫 \u003cstrong\u003eorchestrator-workers\u003c/strong\u003e（orchestrator–工人 pattern — 一個中央 LLM 動態拆解任務、派發給 worker LLM、再彙總結果）的工作流：原文寫「a central LLM dynamically breaks down tasks, delegates them to worker LLMs, and synthesizes their results」。配上 Claude Code 的 \u003ca href=\"https://docs.anthropic.com/en/docs/claude-code/sub-agents\"\u003esubagents\u003c/a\u003e（子代理 — 由主 agent spawn 的獨立 agent，每個都有自己 isolated context window）跟 \u003ca href=\"https://docs.claude.com/en/docs/claude-code/headless\"\u003eheadless mode\u003c/a\u003e（非互動模式 — 用 \u003ccode\u003eclaude -p\u003c/code\u003e 把 prompt 餵進去、跑完吐結果，不開互動對話），就能補上 cutover doc 的戰術缺口 — 也就是「單一高階任務底下，怎麼讓 AI 自己跑數小時的 autonomous loop（自主迴圈 — 不需要人在中間 nudge，loop 自己 plan / execute / evaluate 直到條件達成）而不撞牆」。\u003c/p\u003e","title":"Combining Strategic Cutover Docs with Tactical Headless Orchestrators"},{"content":"The Problem: AI Agents Forget, but the Task Doesn\u0026rsquo;t End 技術社群已經習慣「給人類看的 RFC」— Architecture Decision Records、incident postmortem、設計文件，模板都很成熟。但給 AI agent 之間用的非同步協作文件呢？少有公開討論。\n最近處理一個跨三個 repo 的 schema migration：後端表結構改、前端 GraphQL 切換、行動端讀寫遷移，估計要 30+ 天，會跨越十多個獨立的 Claude session（不同時段、不同任務切片、不同筆電）。每次 session 結束、下一輪起來，前次的 context window 就消失了。問題不只是「上下文遺忘」，還包括：\n上次 session 進度到哪？沒人記得。 之前否決過的方案？新 session 很容易「自信地重新提案」。 跨 repo 的「我改完了、等你那邊配合」訊息，怎麼跨越 session 邊界？ 某次踩的坑，下次會不會重蹈？ 我們最後長出一份 CUTOVER.md 放在獨立的 metadata repo，作為跨 session、跨 repo 的 out-of-band control plane。實戰一個月後，這份文件結晶出 8 個對 AI agent 特別有效的設計模式 — 它們幾乎都對應到分散式系統的經典概念。\nThe Architecture 每個 AI session 開場先 pull doc → 讀完上下文 → 完成任務 → push update。Doc 自己是獨立 repo，與三個 code repo 解耦，扮演純控制平面角色。\nPattern 1: Append-Only Audit Trail 文件用一張「Open Questions」表追蹤待決議事項。AI agent 解決問題後不刪除該條目，而是把它移到 Closed 區、用刪除線標註、附上解決它的 commit hash。\n| # | 議題 | 狀態 | |---|---|---| | 4 | ~~B7 dual-write regression~~ | ✅ Closed 2026-05-15 by backend session, commit `fc914a8` | 為什麼這對 AI 特別重要：LLM 容易「自信地重新提案已被否決的方向」。如果 closed Q 被刪掉，下個 session 開場看到一張空的 Open Q 就會推論「沒問題了」— 更糟的是它可能會重新建構之前討論過、評估過、否決過的方案。\nAppend-only 強制把「曾被討論並 reject」的選項永遠攤在桌上，等於給未來的 session 一個歷史脈絡的強制注入。\nPattern 2: Cross-Session Message Queue 文件的「Follow-up questions」區用 sender → receiver 格式追蹤跨 session 的提問：\n## Follow-up questions ### Open - 2026-05-16 backend → frontend：toast → 持久 UI 重構 (細節...) ### Closed - ~~2026-05-14 frontend → backend：dual-write 為何不寫 link 表~~ → ✅ shipped as `stg-1.93` (`fc914a8`, 2026-05-15) 這個結構讓「跨 session 提問」變成可追蹤的非同步訊息 — 概念上等同 actor model 的 mailbox（並行運算模型：每個 actor 有獨立的訊息隊列、按到達順序處理，發送方不必等待接收方）。發送方不需要等接收方上線；接收方下次 pickup 時自然會掃到 Open 區。\n不這樣做會怎樣：如果跨 session 提問靠對話頻道（Slack、聊天記錄）傳遞，後續 AI session 完全看不到 — 因為新 session 的 context window 只有當下對話，看不到別的 session 的歷史 thread。\n文件化的 message queue 是唯一能跨越 session 邊界的同步機制。\nPattern 3: Pickup Prompt Engineering 文件中「Pickup for X session」段落不是給人類看的 doc — 是給下個 AI agent 開場讀的 prompt。結構固定：\n### Step 0. Session-pickup hygiene git pull --ff-only \u0026amp;\u0026amp; git branch --show-current git log --oneline -5 | head ### Step 1. Reproduce the regression (~5 min) [詳細步驟 + 具體命令] ### Step 2. Investigation [hypothesis + grep 指令範例] ### Critical gotchas - prod 不動：never push to prod-* tag - ECR immutable: 一個版本號只能 push 一次 ### Rollback plan kubectl set image ... =:previous-tag LLM 是 prompt-driven 的，對 AI 來說這份 pickup section 就是它的「task prompt」。把人類習慣的 README 風格改寫成 step-by-step prompt 風格，能顯著降低錯誤率 — 原因有兩層。\n第一，降低 reasoning 負擔。narrative-style README 期待讀者先消化「整體脈絡」再自行「規劃步驟」— 對 LLM 而言這是額外的 reasoning step，每一步都可能引入幻覺。\nProcedural prompt 直接消除規劃階段，把 reasoning capacity（推理預算 — LLM 的 context window 中用於思考的份額）留給「執行中遇到 edge case 怎麼處理」這種真正需要思考的部分。\nLLM 在 procedural / chain-of-thought（要求模型一步一步顯式推理，而非直接給結論）結構的 prompt 上，表現一致顯著優於 free-form narrative。\n第二，允許 partial progress reporting。step-by-step 結構讓 session 中斷時可以精準回報「完成 Step 1-2、卡在 Step 3 的 X」— 下個 session 從中斷點接手。Narrative 結構則只能說「我看了一半」，但「一半」是哪一半完全不清楚。\nCritical gotchas 段是 prompt 防呆、rollback plan 是 prompt 的安全網。兩者本質都是「先把可預期的失敗模式列出來」，避免 LLM 自己即興發揮。\nPattern 4: Last-Updated Session Attribution 文件最頂部有一行：\n**Last updated**：2026-05-22 by mobile session — handled Q1 + Q2 from backend 下個 session 開場第一眼就看到。為什麼這對 AI 特別重要：AI 容易把「文件存在」和「文件是最新」混為一談。如果某個 session 在 stale doc 基礎上開始工作，產出可能跟最新狀態完全脫鉤。\n明確標註「誰、何時、做了什麼」，強迫每個 session 在動手前先驗證「我讀到的是不是最新版本」。\n實戰踩過的坑：某次 session 的 HEAD 在中途被一個 IDE plugin silent checkout 切到 main 分支，session 在錯誤的分支上分析了半小時才發現 baseline 不對。這個事件直接催生了下一個 pattern。\nPattern 5: Side Lessons as Instruction Layer 文件有「Side lessons」段，紀錄每一次踩坑的具體事件與防制 practice：\n### L1. Session pickup must check branch, not just status 某 session 的 HEAD 中途被 silent checkout 切到 main，session 在錯分支 上分析了半小時，最後靠 reflog 才察覺。 Practice for all sessions: - 開場 `git status` AND `git branch --show-current` 並行執行 - Long sessions 定期 `git reflog -10` 確認沒被 silent checkout 這就是 AI 的 contextual fine-tuning（針對特定脈絡的微調 — 不是真的改 model 權重，而是用文件強制注入特定情境的偏誤校正）：把每一次失誤直接寫進控制平面文件，下次任何 session pickup 時都會讀到。\n比 CLAUDE.md 的 global rules 更有針對性 — 只在這個專案、這個 migration 的範疇內生效，不會稀釋 global 規則的訊號。\nSide lessons 本質上是「事後課程化」的失誤資料。\nPattern 6: High-Density Activity Log Notes 文件主表是 Activity log，但 Note 欄是 free-text 200-500 字密度：\n| Date | Session | Tag | Commits | Note (200-500 字) | 每列的 Note 自我完備，包含：what changed、why、affected files、commit hash、verification 方法、orphan data cleanup、side effects、跟其他 row 的因果關係。\n這違反一般 git log 的「短訊息」原則 — 但對象是 AI 而不是人類。人類看 git log --oneline 後可以延伸去 git show 翻細節，但 AI 翻 N 個 commit 等於消耗 N 倍 context budget。\n把上下文一次壓縮在 Note 欄、每行自我完備，是給 LLM 最高的訊息密度。一個 session 只需要讀這張表就能還原前 30 天的全部設計決策。\n脫敏範例（一個典型 row 的 Note 欄）：\n| 2026-05-15 | backend | stg-1.93 | fc914a8 | Dual-write regression fixed. Root cause: extractDocId in src/utils/relation-pair.ts only recognized documentId strings + {connect:...} envelopes, but frontend saveDraft posts category: \u0026lt;legacy.id\u0026gt; as a raw number, so autoFillRelationPair returned without populating data.canonicalRef and the controller skipped the records_canonical_lnk insert. Replaced with extractRelationRef returning tagged {kind:'doc'\\|'num'} so resolvers pick the right lookup column. STG E2E verified: POST /api/records with category:213 → new record id=4771 → DB confirms both link rows populated. Orphans cleaned: ids 4768-4772 (5 test records from prior runs). 4 regression unit tests added. |\n一個 row 包含 root cause、failing code path、replacement design、verification proof、cleanup action、test coverage — 全部 self-contained。下個 session 不需要去翻 commit fc914a8 也能完全還原這次的 work。\n代價是「人類覺得難讀」。但這份 doc 的主要讀者不是人類，是 AI。\nPattern 7: Async Pickup, No Schedule 文件中所有 Pickup section 都標註：「無時程限制 — X session 可即刻接手，不必等明天」。AI session 不像人類有上下班，任何時候完成手上工作、發現有 Open Q → 就接手。\n這個 pattern 把「session orchestration」從顯式工作流程（「現在輪到誰」）改成隱式 task queue（「誰來都行，照規則做」）。\n實作成本接近零 — 只要每個 session 都遵守「先 pull → 讀 Open Q → 做完 push」就行。\n實戰一個月下來，這份 doc 累積過大約十條 cross-session questions，多數在 raise 後同一天內就被某個空閒 session 接手關閉 — 因為任何 session 開場 pickup 時都會主動掃 Open Q 區、看到能接的就接。\n預期中「等到下次某人有空、再分配工作」這個瓶頸幾乎完全消失。\nPattern 8: Cross-Repo State Baton 這份控制文件不放在任何一個 code repo，而是放在自己獨立的 metadata repo。\n聽起來像瑣事，但這是整套機制不能省的關鍵設計 — 拿掉就會崩、沒有別的位置能取代。\n為什麼？\n跨 repo 公平：協調工作橫跨三個 code repo（後端 / 前端 / 行動端）。doc 放在任何一個 code repo 都會「偏心」— 例如放在後端 repo 裡，前端 session 開工時根本不會去 clone 後端 repo 找 doc。 互不干擾：metadata repo 跟 code 的 commit / PR / branch 完全分開 — doc 可以一天 commit 50 次、三個 code repo 都不會被吵到，doc 改動也不會污染 code 的 PR diff。 一次取齊：每個 session 只需要 clone 這一個輕量 repo，就拿到全部跨專案的上下文，不必各別 clone 三個 code repo。 獨立通道：控制訊息（doc）跟被協調的東西（code）走不同通道。這在網路與分散式系統圈有專有名詞叫 out-of-band control plane（OOB 控制平面，意思就是「帶外」— 控制走自己的路、不擠在資料的路上）。 這不是 AI 協作獨有的設計，而是分散式系統幾十年來反覆驗證的同一招：\nBGP（網際網路骨幹的路由協定）— 路由表變更走專屬通道，跟資料封包走不同網路。 OpenFlow（SDN 軟體定義網路的協定）— 把網路設備的控制邏輯抽到中央 controller，跟資料轉送分離。 etcd（Kubernetes 拿來存叢集狀態的 key-value store）— 叢集狀態跟應用層流量完全分開。 Cutover doc（跨 session AI 協作的控制平面）— 跟 code commit 放在不同的 repo。 這四個系統的共通做法：把「下指令的訊息」跟「被指令影響的東西」物理性分開。控制邏輯就不會被海量資料淹沒。\nThe Token Economics 值得算一下成本。一份 5000 字（中英混合，約 7500 tokens）的 cutover doc，等於 Claude 200K context window 的 3.7%。\n替代方案：讓 session 不靠 doc、純靠原始 git history 重建上下文 — 翻 30 個相關 commits 經由 git show 約消耗 30K tokens，再讀 5 個 PR threads 補設計脈絡 ≈ 10K tokens：\n兩條路的差距 5 倍。而且這 40,000 tokens 是極低密度的原料 — 大部分是 import statements、format 改動、與本任務無關的雜訊。\n更糟的是：raw commits 沒有「為什麼這個方案被選、那個方案被否決」的記載。這些討論發生在已消失的對話脈絡裡，靠 commit history 永遠翻不出來。\nDoc 是唯一把這類元決策保留下來的地方。\n5 倍 token 成本不只是錢的問題（雖然也是），更是 reasoning budget 的問題 — 同樣的 context window，用 7500 tokens 讀完 doc 後還剩 ~192K tokens 處理任務本身；換成讀 40000 tokens 的 raw history，能處理任務的緩衝就少了 4 倍以上。\n對長時程、需要多輪推理的任務，這個差距會放大成「能做完 vs 中途 OOM（Out Of Memory — context window 撐爆、session 無法繼續）」的分水嶺。\nExtending to Other Scenarios 這 8 個 pattern 不只適用 schema migration。任何跨多 session、跨多 repo、長時程的 AI 任務都適用：\nIncident handling：每個 incident 開一份 doc。Open Q = 待調查；Closed = 已 root cause + 修復 commit。 Multi-PR refactoring：拆成 5+ PR 的大重構，doc 追蹤每個 PR 的依賴、進度、blocker。 Feature flag rollouts：每個 flag 一份 doc，記錄 ramp 排程、metric 觀察、rollback 決策、最終 cleanup 時程。 Long-running data pipeline 重寫：跨多月、多階段的 ETL 重做，每個階段一個 row。 核心問題很簡單：「這個任務無法在單一 session 完成、需要多個 AI agent 接力嗎？」如果是，就值得用這套 pattern。\nWhen NOT to Use This 過早採用 = 過度設計。以下情境不建議：\n單 session 可完成的小任務：寫一個 component、修一個 bug。用 PR 描述本身當紀錄就好，doc 會變成 overhead。 沒有 git pull 紀律的協作：如果 session 不會主動 git pull --ff-only 開場，doc 永遠 stale，這套機制崩潰。前提是所有 session 都遵守 pickup hygiene。 任務沒有跨 boundary 性質：例如純前端的功能開發、單一 service 內的調整，控制平面拉出來等於空轉。 文件超過某個複雜度：當 Activity log 超過 50 列、Open Q 超過 20 條，doc 自己變成負擔。此時拆系列（per-phase doc）比繼續累積健康。 建議的採用時機：第一次踩到「跨 session 上下文遺失」之後再採用，作為事後的解法，而不是預先的 architecture。\n事後採用比預先設計更精準，因為你已經知道痛點長什麼樣。\nClosing: Writing for Agents, Not for Humans AI agent 之間的協作本質上是非同步、有遺忘症、無共享上下文的。\n傳統「給人類看的設計文件」（架構圖、ADR、postmortem）不夠 — 因為人類有記憶、可以打 Slack 問同事、可以累積默契；但 AI agent 每一次都從零開始，看到的只有檔案系統的當下狀態。\n寫給 AI 的控制平面文件需要不同的紀律：append-only、高密度、結構化為 prompt、跨 repo 獨立部署、把失誤事後課程化。\n這 8 個 pattern 從一個月實戰結晶而來。回頭看，它們幾乎都對應到分散式系統的經典問題：\nstate machine replication（狀態機複製 — 多節點按相同順序執行相同操作，最終達到一致狀態） message queue（訊息隊列 — 非同步通訊機制，發送方不必等接收方上線） actor mailbox（演員模型郵箱 — 每個 actor 有獨立的訊息隊列） out-of-band control plane（帶外控制平面 — 控制訊息與資料訊息走不同通道） 如果你下次發現一個 AI 任務需要跨多個 session、多個 repo — 別急著開新 PR。先 commit 一份 cutover doc 進獨立 metadata repo，把這 8 個 pattern 套上去。\n一個月後，當你發現新開的 session 第一次 pickup 就能流暢接手 Open Q、不重做已被 reject 的方案、不重複問初級問題 — 你會慶幸當初花的那 30 分鐘。\n","permalink":"https://peter-notes.com/posts/async-multi-agent-coordination-via-markdown-control-plane/","summary":"\u003ch2 id=\"the-problem-ai-agents-forget-but-the-task-doesnt-end\"\u003eThe Problem: AI Agents Forget, but the Task Doesn\u0026rsquo;t End\u003c/h2\u003e\n\u003cp\u003e技術社群已經習慣「給人類看的 RFC」— Architecture Decision Records、incident postmortem、設計文件，模板都很成熟。但給 AI agent 之間用的非同步協作文件呢？少有公開討論。\u003c/p\u003e\n\u003cp\u003e最近處理一個跨三個 repo 的 schema migration：後端表結構改、前端 GraphQL 切換、行動端讀寫遷移，估計要 30+ 天，會跨越十多個獨立的 Claude session（不同時段、不同任務切片、不同筆電）。每次 session 結束、下一輪起來，前次的 context window 就消失了。問題不只是「上下文遺忘」，還包括：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e上次 session 進度到哪？沒人記得。\u003c/li\u003e\n\u003cli\u003e之前否決過的方案？新 session 很容易「自信地重新提案」。\u003c/li\u003e\n\u003cli\u003e跨 repo 的「我改完了、等你那邊配合」訊息，怎麼跨越 session 邊界？\u003c/li\u003e\n\u003cli\u003e某次踩的坑，下次會不會重蹈？\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e我們最後長出一份 \u003ccode\u003eCUTOVER.md\u003c/code\u003e 放在獨立的 metadata repo，作為跨 session、跨 repo 的 out-of-band control plane。實戰一個月後，這份文件結晶出 8 個對 AI agent 特別有效的設計模式 — 它們幾乎都對應到分散式系統的經典概念。\u003c/p\u003e\n\u003ch2 id=\"the-architecture\"\u003eThe Architecture\u003c/h2\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/async-multi-agent-coordination-via-markdown-control-plane-diagram-358313a9.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e每個 AI session 開場先 pull doc → 讀完上下文 → 完成任務 → push update。Doc 自己是獨立 repo，與三個 code repo 解耦，扮演純控制平面角色。\u003c/p\u003e","title":"Designing a Control-Plane Document for Async Multi-Session AI Agents"},{"content":"起因：工具 Pod 人間蒸發 某天要查資料庫，照慣例執行 kubectl exec -it psql-client，結果 Pod 不見了。\n這個 psql-client 是用來連 RDS PostgreSQL 的工具容器，平常拿它跑 SQL 查詢、檢查資料表大小。問題是——沒有人記得刪過它，也沒有任何記錄顯示是誰或什麼原因讓它消失。\n這件事本身影響不大，重建一個就好。但它暴露了三個更深層的問題：為什麼 Pod 會無聲消失？為什麼查不到是誰刪的？為什麼整個叢集沒有留下任何操作軌跡？\n陷阱一：裸 Pod 沒有人管它的死活 問題本質 當初建立 psql-client 的指令大概是這樣：\nkubectl run psql-client --image=postgres:15-alpine --restart=Never -- sleep infinity 這行指令建立的是一個裸 Pod（Bare Pod）——直接建立 Pod 物件，不隸屬於任何 Deployment、ReplicaSet 或 StatefulSet。\n裸 Pod 和 Deployment 管理的 Pod，差別在於有沒有 Controller 在背後看著它。Deployment 的 ReplicaSet Controller 會持續監控 Pod 數量，少一個就補一個。裸 Pod 沒有 Controller，它的生死完全取決於節點的命運。\n不處理會怎樣 裸 Pod 在以下情境會永久消失：\n節點縮容：Auto Scaler 移除節點時，上面的裸 Pod 直接消失，不會被重新排程 節點升級：EKS 節點群組更新 AMI 時，舊節點上的裸 Pod 隨之銷毀 節點故障：底層 EC2 掛掉，裸 Pod 不會在其他節點重建 驅逐（Eviction）：節點資源不足時，裸 Pod 通常最先被驅逐 關鍵在於：這些事件都不會產生告警。Pod 就是靜靜地消失了，直到你某天需要用它才發現。\n解決方案：用 Deployment 管理 apiVersion: apps/v1 kind: Deployment metadata: name: psql-client spec: replicas: 1 selector: matchLabels: app: psql-client template: metadata: labels: app: psql-client spec: containers: - name: psql-client image: postgres:15-alpine command: [\u0026#34;sleep\u0026#34;, \u0026#34;infinity\u0026#34;] resources: requests: cpu: 10m memory: 32Mi limits: cpu: 100m memory: 128Mi 為什麼要設 resources？因為這是一個 24/7 運行但幾乎不做事的容器。cpu: 10m 代表 0.01 核心，memory: 32Mi 是最低限度的記憶體。設定 requests 也能避免它在資源緊張時被最先驅逐——沒有 requests 的 Pod 屬於 BestEffort QoS 等級，是驅逐優先順序最高的。\n有人可能會問：一個偶爾才用的工具，有必要 24/7 跑著嗎？確實可以改成「需要時才建、用完就刪」的模式。但如果團隊成員不熟悉 kubectl 指令，一個隨時可用的工具 Pod 省去的溝通成本遠超那 10m CPU。\n陷阱二：K8s Events 只活一小時 發現 Pod 消失後，第一反應是查 Events：\nkubectl get events --field-selector involvedObject.name=psql-client 結果：No resources found。\n為什麼查不到 Kubernetes Events 的預設 TTL 是 1 小時。這是 kube-apiserver 的 --event-ttl 參數決定的，而在 EKS 中，你無法修改這個參數——因為 Control Plane 由 AWS 託管。\n1 小時意味著什麼？意味著只要你不是在事件發生後立刻去查，所有線索都已經消失。Pod 被驅逐、被刪除、OOMKilled——什麼都查不到。\n為什麼這麼短 這不是 AWS 的限制，而是 Kubernetes 的設計選擇。Events 儲存在 etcd 中，而 etcd 是整個叢集的核心狀態儲存。如果 Events 保留太久，etcd 的儲存壓力會影響叢集效能。1 小時是在「可追溯性」和「儲存效率」之間的取捨。\n怎麼解 既然不能改 TTL，就把 Events 導出去。在 EKS 上，最直接的方式是啟用 Control Plane Logging 中的 Audit Log，讓所有 API 操作記錄進 CloudWatch Logs（下一節會詳細說明）。\n關鍵差異：etcd Events 是 K8s 內部的短期紀錄，Audit Log 則是持久化到外部儲存的完整操作日誌。兩者記錄的內容也不同——Events 偏向「發生了什麼事」（Pod scheduled、image pulled），Audit Log 偏向「誰做了什麼操作」（user X deleted pod Y）。\n陷阱三：EKS Audit Log 預設是關閉的 查完 Events 之後，我試著從 CloudWatch Logs 找線索：\naws logs filter-log-events \\ --log-group-name \u0026#34;/aws/eks/my-cluster/cluster\u0026#34; \\ --filter-pattern \u0026#39;\u0026#34;psql-client\u0026#34; \u0026#34;delete\u0026#34;\u0026#39; \\ --region ap-east-2 結果：ResourceNotFoundException。Log Group 根本不存在。\n預設狀態 EKS 的 Control Plane Logging 有五種日誌類型：\n日誌類型 內容 預設狀態 api API Server 請求/回應 關閉 audit 誰在什麼時候做了什麼 關閉 authenticator 身份驗證事件 關閉 controllerManager Controller 運作日誌 關閉 scheduler Pod 排程決策 關閉 全部預設關閉。 這代表除非你主動啟用，否則叢集上發生的所有操作都不會留下紀錄。有人刪了 Production 的 Deployment？查不到。有人改了 Secret？查不到。\n啟用 Audit Log aws eks update-cluster-config \\ --name my-cluster \\ --region ap-east-2 \\ --logging \u0026#39;{\u0026#34;clusterLogging\u0026#34;:[{\u0026#34;types\u0026#34;:[\u0026#34;audit\u0026#34;],\u0026#34;enabled\u0026#34;:true}]}\u0026#39; 啟用後，所有 Kubernetes API 呼叫都會記錄到 CloudWatch Logs 的 /aws/eks/\u0026lt;cluster-name\u0026gt;/cluster Log Group。每筆記錄包含：\n誰：操作者的身份（IAM User/Role） 什麼時候：精確時間戳 做了什麼：API 動詞（create、delete、patch\u0026hellip;） 對什麼資源：namespace、resource type、resource name 從哪裡：來源 IP 費用控制：設定保留期限 Audit Log 的量不小——每個 API 呼叫都會產生一筆記錄。CloudWatch Logs 預設保留期限是永久，這會導致儲存費用持續累積。\naws logs put-retention-policy \\ --log-group-name \u0026#34;/aws/eks/my-cluster/cluster\u0026#34; \\ --retention-in-days 60 60 天足以涵蓋大多數事後追查的需求。如果有合規要求需要更長的保留期間，可以考慮將日誌導出到 S3（成本約為 CloudWatch 的十分之一）。\n結語 這三個問題有一個共同點：在出事之前，你完全不會意識到它們的存在。裸 Pod 好好跑著、Events 查不查無所謂、Audit Log 沒開也不影響服務。直到某天需要追查問題時，才發現所有線索都已經不在了。\n維運的本質不是救火，而是在火災發生前確保消防系統可用。這三個設定——Deployment 管理工具 Pod、理解 Events TTL 的限制、啟用 Audit Log——都是花十分鐘就能完成的事，但能在關鍵時刻省下幾小時的盲目排查。\n","permalink":"https://peter-notes.com/posts/eks-ops-bare-pod-audit-log-pitfalls/","summary":"\u003ch2 id=\"起因工具-pod-人間蒸發\"\u003e起因：工具 Pod 人間蒸發\u003c/h2\u003e\n\u003cp\u003e某天要查資料庫，照慣例執行 \u003ccode\u003ekubectl exec -it psql-client\u003c/code\u003e，結果 Pod 不見了。\u003c/p\u003e\n\u003cp\u003e這個 \u003ccode\u003epsql-client\u003c/code\u003e 是用來連 RDS PostgreSQL 的工具容器，平常拿它跑 SQL 查詢、檢查資料表大小。問題是——沒有人記得刪過它，也沒有任何記錄顯示是誰或什麼原因讓它消失。\u003c/p\u003e\n\u003cp\u003e這件事本身影響不大，重建一個就好。但它暴露了三個更深層的問題：\u003cstrong\u003e為什麼 Pod 會無聲消失？為什麼查不到是誰刪的？為什麼整個叢集沒有留下任何操作軌跡？\u003c/strong\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"陷阱一裸-pod-沒有人管它的死活\"\u003e陷阱一：裸 Pod 沒有人管它的死活\u003c/h2\u003e\n\u003ch3 id=\"問題本質\"\u003e問題本質\u003c/h3\u003e\n\u003cp\u003e當初建立 \u003ccode\u003epsql-client\u003c/code\u003e 的指令大概是這樣：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ekubectl run psql-client --image\u003cspan class=\"o\"\u003e=\u003c/span\u003epostgres:15-alpine --restart\u003cspan class=\"o\"\u003e=\u003c/span\u003eNever -- sleep infinity\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e這行指令建立的是一個\u003cstrong\u003e裸 Pod（Bare Pod）\u003c/strong\u003e——直接建立 Pod 物件，不隸屬於任何 Deployment、ReplicaSet 或 StatefulSet。\u003c/p\u003e\n\u003cp\u003e裸 Pod 和 Deployment 管理的 Pod，差別在於\u003cstrong\u003e有沒有 Controller 在背後看著它\u003c/strong\u003e。Deployment 的 ReplicaSet Controller 會持續監控 Pod 數量，少一個就補一個。裸 Pod 沒有 Controller，它的生死完全取決於節點的命運。\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/eks-ops-bare-pod-audit-log-pitfalls-diagram-562f124f.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003ch3 id=\"不處理會怎樣\"\u003e不處理會怎樣\u003c/h3\u003e\n\u003cp\u003e裸 Pod 在以下情境會永久消失：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e節點縮容\u003c/strong\u003e：Auto Scaler 移除節點時，上面的裸 Pod 直接消失，不會被重新排程\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e節點升級\u003c/strong\u003e：EKS 節點群組更新 AMI 時，舊節點上的裸 Pod 隨之銷毀\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e節點故障\u003c/strong\u003e：底層 EC2 掛掉，裸 Pod 不會在其他節點重建\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e驅逐（Eviction）\u003c/strong\u003e：節點資源不足時，裸 Pod 通常最先被驅逐\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e關鍵在於：\u003cstrong\u003e這些事件都不會產生告警\u003c/strong\u003e。Pod 就是靜靜地消失了，直到你某天需要用它才發現。\u003c/p\u003e","title":"EKS 維運的三個隱形陷阱：工具 Pod 消失、事件蒸發、審計空白"},{"content":"前言：frontend 修補的驗證最常被推給人手點 修了一個 frontend modal 的提交守門 bug，把改動推上 STG（staging 環境），下一步「驗證」往往就是寄訊息給 QA 或產品經理：「請開瀏覽器點點看，看 UI 守門有沒有生效。」\n這個步驟在 CI 跑完單元測試後通常還是被當成人類的責任，原因是 E2E（end-to-end，端到端）環境難搭、寫一次 Playwright 腳本要花的時間比人手點還久、而且 UI 本來就會變動。\n但隨著 LLM agent 配上瀏覽器自動化能力，這個 trade-off 已經悄悄反轉了。Bot 不只能省掉人手點擊的時間，更會做一件人類常常忘了做的事：在 UI 看似成功之後，去資料庫驗證資料真的寫對了。 這篇文章記錄一次真實的 STG 驗收，原本要驗 frontend 修補，卻意外揭發 backend 的 dual-write regression（雙寫機制失效）。\nPlaywright MCP 是什麼，為什麼是它打破僵局 先把兩個關鍵字釐清：\nPlaywright 是微軟釋出的瀏覽器自動化框架，原本是寫 JavaScript 或 Python 腳本控制 chromium。\nMCP（Model Context Protocol） 是 Anthropic 推的開放協議，把外部工具用宣告式 schema 包成 LLM 可呼叫的 server，類似 LSP（Language Server Protocol）之於編輯器——讓不同 IDE 共用同一個語言分析後端。LLM 客戶端啟動時會跟 MCP server 握手取得可用 tool 清單，之後對話中就能像呼叫 function 一樣直接用。\n把兩者合起來，Playwright MCP 就是把 chromium 包成 MCP server，LLM 透過 browser_navigate、browser_click、browser_snapshot、browser_evaluate 這幾個 tool 直接驅動瀏覽器。裝在 user scope 一次就好：\nclaude mcp add playwright -s user -- npx @playwright/mcp@latest 之後任何 Claude Code session 開場就能呼叫，第一次 navigate 才會下載 ~150 MB 的 chromium。\n它真正改變遊戲規則的不是「能驅動瀏覽器」這件事——Playwright 早就能做到——而是 agent 能根據當下看到的頁面內容決定下一步。傳統 Playwright 腳本是寫死的：點按鈕 A、等 selector B 出現、斷言文字 C。Agent 拿到頁面 snapshot 後可以自己判斷：欄位該填什麼、要不要先 cancel 才能進下一步、看到非預期 modal 該怎麼繞。\n寫一支健壯的 Playwright 腳本要半天，請 agent 跑同樣的場景不到五分鐘。\nReconnaissance-then-action：四個場景的設計 這次驗證涵蓋一個 modal 守門修補，總共四個場景：\n場景 操作 預期 1. 守門 切換到「+ 新增類型」mode、輸入字串、不按確認，直接送出 主送出按鈕 disabled 2. Happy path 選預設類型、填日期、送出 success toast、列表出現新項目 3. Twin modal 另一個 modal 用相同 UX 對稱守門 4. 中文錯誤 輸入會被 backend 拒絕的重複名稱 toast 顯示中文訊息 驅動者 agent 採用 **reconnaissance-then-action（先偵察、後行動）**模式：每動一步就先 browser_snapshot 看當下 DOM 結構，根據 ref 找到正確的 element，再 browser_click。這個迴圈聽起來慢，實際上每一步只多一次 LLM 推理，比寫死的腳本更能適應 selector drift（CSS class hash 變動、元素層級重構導致寫死 selector 失效）。\n關鍵技巧是混用兩個工具：\nbrowser_snapshot 拿的是 accessibility tree——瀏覽器為螢幕閱讀器建構出來的結構化 DOM，比原始 HTML 輕量、語意更清楚（headings、buttons、inputs 都標好角色）。快速但有時候少資訊。 browser_evaluate 直接執行任意 JS，可以看 disabled 屬性、title tooltip、computed style、甚至打 fetch。 場景 1 驗證守門時，光看 snapshot 知道按鈕有 disabled 標記還不夠——需要 browser_evaluate 去讀 title 屬性確認 tooltip 文字也是中文。\n關鍵判斷：POST 200 真的代表寫對了嗎？ 場景 2 是這次發現問題的引爆點。Agent 點完送出按鈕後：\n出現確認 dialog → 點「確定」 → modal 關閉 → 回到列表頁 browser_network_requests 顯示 POST /api/records 回 200 UI 列表計數仍然是「共 1 筆」 如果是人手 UAT，看到 modal 關閉、沒紅色錯誤、就會打勾收工。「列表沒立刻更新」常被歸咎於 cache 或頁面 refresh 時機，使用者頂多 F5 重整一次，看到還是 1 筆，可能會懷疑是不是自己之前算錯了。\nAgent 不會這樣讓步。它直接做了一件人手很少做的事——用 sessionStorage 裡的 token 直接打 API，繞過 UI 看資料的真實狀態：\n// 透過 browser_evaluate 在頁面 context 內 // 借用 app 自己存的 token，繞過 UI 看資料 async () =\u0026gt; { const token = sessionStorage.getItem(\u0026#39;app_token\u0026#39;) // populate=* 是 Strapi 慣例：預設 API 不會展開 relation 欄位， // 必須明確指定才會把關聯資料一起回來 const r = await fetch( \u0026#39;/api/records?filters[owner][id][$eq]=213\u0026amp;populate=*\u0026#39;, { headers: { Authorization: \u0026#39;Bearer \u0026#39; + token } } ) return (await r.json()).meta.pagination.total } // 回傳：1 (跟 UI 一致) // 但用 createdAt desc 全表查，新建 record 確實存在於資料庫 兩個查詢結果矛盾：用 owner filter 撈不到，但全表 sort by createdAt 看得到。這時候真相只能再深入一步：直接 populate 那筆 record 的所有 relation。結果是新 record 的 primary owner 連結正常，但走 partial cut-over 後新引入的 ownerUser 連結為 null。\n揭發的 backend bug：dual-write lifecycle hook 失效 要看懂這個 bug 先補三個概念。\nLink table（中介表 / junction table）：關聯式資料庫表達多對多關聯的標準做法。records 跟 users 之間如果是多對多，會用一張 records_users_lnk 表存 (record_id, user_id) 對應。Strapi 把每個 relation 自動建一張這種表。\nLifecycle hook：ORM 在 create / update / delete 前後 fire 的 callback。例如 beforeCreate 可以在資料寫入前自動填欄位、補預設值、或做驗證。\nDual-write 與 partial cut-over：schema 遷移的常見策略。當要把舊的 owner（指向 legacy 表）漸進換成新的 ownerUser（指向統一的 users 表），無法一次切換，就同時保留兩個欄位、寫入時兩邊都寫（dual-write）、讀取則先切到新 schema（partial cut-over），舊欄位留著當 backward compatibility。\n回到這次的故事。後端正在做這個遷移：舊的 owner 連結保留，新的 ownerUser 連結並行寫入，find controller 已經切到走新連結 join。為了避免前端要改寫，後端設計了一個 lifecycle hook（autoFillPair）：當前端只送 owner 時，hook 會在 beforeCreate 階段自動把對應的 ownerUser 填好。\nBackend session 接手後挖到的真因比想像中更隱晦：lifecycle hook 有觸發，但內部一個 helper extractDocId 只認得 documentId 字串跟 {connect: ...} envelope，而 frontend 送的是裸 number（例如 owner: 213）。Helper 拿到 number 直接 early return，hook 後續寫 ownerUser 的程式碼根本沒跑。\nStrapi v5 的一個容易被忽略的細節：每筆資料同時有 numeric id（traditional pk）跟字串 documentId（v5 新引入用於 draft \u0026amp; publish）。寫 lifecycle helper 時很容易只想到 documentId 路徑，但 REST API 仍接受 numeric id 作為 relation 寫入輸入。\n修法是把 helper 換成 extractRelationRef，回傳 tagged union {kind: 'doc' | 'num', value}，讓上層根據 kind 選對的查詢欄位。同時補了 4 個 regression unit test 涵蓋 number / numeric string / bare {id} / documentId / connect 五種輸入形式。整個修補從 backend session 接手到 STG 驗證通過約半天，frontend 驗證腳本已經完成自己的職責：修補本身有效（POST 不再回 400），同時把獨立的 backend regression 完整揭發、留下清楚的 reproduction recipe 給下一棒。\n為什麼人手 UAT 通常碰不到這個 回頭看人手 UAT 的標準動線就懂了：\n步驟 人手 UAT LLM agent UI 守門 ✓ 點點看 ✓ snapshot + evaluate 雙驗 正常流程 ✓ 看 toast ✓ 看 toast 也看 network 列表更新 F5 一次，沒更新就頂多回報 直接打 API 比對 metadata + total Relation 完整性 不在腳本內 自然會 populate 確認 Cleanup orphan 容易忘記 主動列出來請 backend 處理 人手 UAT 的盲點不是能力問題，是動機問題：人手點完十個案例已經累了，看到 UI 沒爆就會想趕快收工；agent 沒有時間成本壓力，它「再多查一個 API confirm 一下」的邊際成本是零。\n三個實戰陣圍：把這個習慣寫進 verification recipe 把今天踩到的經驗一般化成可重複的 recipe：\n陣圍一：審潔（assertion completeness）。 任何「create / update」操作都應該至少驗 UI、API status、API response body 三個層面。如果操作會影響列表，第四層是「重新拉列表確認新資料出現在預期位置」。POST 200 是必要不充分條件——它只證明請求到了 server 沒拋 exception，不證明資料寫進對的關聯。\n陣圍二：cleanup 責任明確。 Agent 在 STG 跑驗證時會留下測試資料。每個 verification recipe 結尾要有 cleanup 步驟（SQL 或 API delete），或至少把孤兒資料（orphan record）ID 寫進 handoff doc 由下個 session 處理。今天那筆 orphan record 已經寫進跨 session 協調文件，等修好後一起清。\n陣圍三：idempotency 預設。 驗證腳本應該能重跑，不會因為前一次留下的狀態爆炸。例如場景 4 故意觸發「重複名稱被拒」，這就天然 idempotent；如果是場景 2 真的會建立新 record，腳本就要先檢查當下總數、做完後驗證 +1，而不是寫死「應該總共 5 筆」。\n結語 Playwright MCP、LLM agent、CI/CD 這些都是工具。真正的價值在於 design：把驗證的責任從「讓使用者覺得沒爆」推進到「讓資料真的寫對」。\nLLM 驅動的 E2E 沒有取代人手 UAT，而是把人手的時間從點擊解放出來，去做更需要判斷的事——例如審視驗證 recipe 本身是否覆蓋了所有「沉默失敗」的可能。今天這個 regression 不是 frontend 修補造成的，但如果沒有這次 bot 驅動的驗證、它可能要等下一輪客戶反饋才會被發現。\n下次你寫 verification 腳本時，多寫那一行 populate=* 然後比對結果。多花的五秒可能會省掉下週的緊急 hotfix。\n","permalink":"https://peter-notes.com/posts/llm-driven-e2e-surfaces-backend-regression/","summary":"\u003ch2 id=\"前言frontend-修補的驗證最常被推給人手點\"\u003e前言：frontend 修補的驗證最常被推給人手點\u003c/h2\u003e\n\u003cp\u003e修了一個 frontend modal 的提交守門 bug，把改動推上 STG（staging 環境），下一步「驗證」往往就是寄訊息給 QA 或產品經理：「請開瀏覽器點點看，看 UI 守門有沒有生效。」\u003c/p\u003e\n\u003cp\u003e這個步驟在 CI 跑完單元測試後通常還是被當成人類的責任，原因是 E2E（end-to-end，端到端）環境難搭、寫一次 Playwright 腳本要花的時間比人手點還久、而且 UI 本來就會變動。\u003c/p\u003e\n\u003cp\u003e但隨著 LLM agent 配上瀏覽器自動化能力，這個 trade-off 已經悄悄反轉了。\u003cstrong\u003eBot 不只能省掉人手點擊的時間，更會做一件人類常常忘了做的事：在 UI 看似成功之後，去資料庫驗證資料真的寫對了。\u003c/strong\u003e 這篇文章記錄一次真實的 STG 驗收，原本要驗 frontend 修補，卻意外揭發 backend 的 dual-write regression（雙寫機制失效）。\u003c/p\u003e\n\u003ch2 id=\"playwright-mcp-是什麼為什麼是它打破僵局\"\u003ePlaywright MCP 是什麼，為什麼是它打破僵局\u003c/h2\u003e\n\u003cp\u003e先把兩個關鍵字釐清：\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003ePlaywright\u003c/strong\u003e 是微軟釋出的瀏覽器自動化框架，原本是寫 JavaScript 或 Python 腳本控制 chromium。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eMCP（Model Context Protocol）\u003c/strong\u003e 是 Anthropic 推的開放協議，把外部工具用宣告式 schema 包成 LLM 可呼叫的 server，類似 LSP（Language Server Protocol）之於編輯器——讓不同 IDE 共用同一個語言分析後端。LLM 客戶端啟動時會跟 MCP server 握手取得可用 tool 清單，之後對話中就能像呼叫 function 一樣直接用。\u003c/p\u003e","title":"LLM 驅動的 E2E 驗證：為什麼 bot 比人更容易揭發 backend regression"},{"content":"引言:當業務主表需要翻轉 某個 SaaS 系統長期以「客戶資料表」為核心:所有訂單、文件、操作紀錄都用客戶 ID 當外鍵。但這個客戶表的資料來源是外部 POS 系統匯入,每天同步幾百筆,schema 由廠商定義。\n業務發展後問題浮現:自己 SaaS 的會員表(users)才是真正的「人」 — 有登入、有偏好、有應用內行為。新功能(個人化推薦、訂閱管理、社交綁定)都需要以 users 為主軸。\n於是有了一個經典的 schema migration 需求:主表翻轉(primary table pivot)。把業務邏輯的中心從 customers(外部 POS 匯入)轉到 users(SaaS 自家會員),但歷史資料、新進資料、系統相依、回滾風險全部都要顧到。\n名詞解釋 開始拆解之前,定義幾個會反覆出現的詞:\n詞 定義 主表翻轉(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 的條件不允許:\n維度 Stop-the-world Dual-write 過渡 Downtime 30 分鐘 ~ 數小時 0 downtime Rollback 需 restore DB(風險高) Image 退版即可 Migration 失敗代價 全部回滾 + downtime 拉長 失敗的紀錄事後補,線上不受影響 開發成本 低(一支 script) 高(三階段 + lifecycle hook + dual-write API) 資料一致性風險 集中在維護窗 分散在過渡期,每筆都驗證 選 dual-write 的核心原因不是「成本最低」,是 「失敗代價最低」。Stop-the-world 一旦 batch script 跑到一半某筆資料炸了,整個系統卡住,回滾還要面對「部分轉檔成功、部分失敗」的混亂狀態。Dual-write 過渡期間每筆寫入都同時走新舊兩條路,任何一條失敗都還有另一條當 fallback。\n不選會怎樣:見過某團隊選 stop-the-world,預估 1 小時搞定,實際跑 4 小時、跑壞兩個 foreign key constraint、最後 restore from backup 重來。隔天會議檢討整整一週。\nSchema 設計:兩個外鍵並存 第一步是讓引用客戶的業務表(姑且叫 documents,可能是訂單、報告、操作紀錄)同時持有兩個外鍵:\n{ \u0026#34;attributes\u0026#34;: { \u0026#34;customer\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;relation\u0026#34;, \u0026#34;relation\u0026#34;: \u0026#34;manyToOne\u0026#34;, \u0026#34;target\u0026#34;: \u0026#34;api::customer.customer\u0026#34; }, \u0026#34;customerUser\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;relation\u0026#34;, \u0026#34;relation\u0026#34;: \u0026#34;manyToOne\u0026#34;, \u0026#34;target\u0026#34;: \u0026#34;plugin::users-permissions.user\u0026#34; } } } 兩個欄位都 nullable — 過渡期允許「只有舊外鍵、沒有新外鍵」的紀錄存在(很短的搬遷視窗內),也允許「只有新外鍵、沒有舊外鍵」(純 SaaS 註冊的會員從未在外部 POS 出現過的情境)。\nusers schema 同時加反向關聯,方便 ORM populate:\n{ \u0026#34;attributes\u0026#34;: { \u0026#34;userType\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;enumeration\u0026#34;, \u0026#34;enum\u0026#34;: [\u0026#34;member\u0026#34;, \u0026#34;offline\u0026#34;], \u0026#34;default\u0026#34;: \u0026#34;member\u0026#34; }, \u0026#34;documents\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;relation\u0026#34;, \u0026#34;relation\u0026#34;: \u0026#34;oneToMany\u0026#34;, \u0026#34;target\u0026#34;: \u0026#34;api::document.document\u0026#34;, \u0026#34;mappedBy\u0026#34;: \u0026#34;customerUser\u0026#34; } } } userType 區分 SaaS app 註冊的真會員(member)與從外部 POS 匯入但沒對應 SaaS 帳號的客戶(offline,後面會建 pseudo user 給他們)。\nMigration 三階段腳本 歷史資料怎麼補上 customerUser?拆成三個獨立、各自冪等的腳本。\nPhase 1:配對既有 user + 搬欄位 外部客戶資料中有些「真會員」其實也用 SaaS app 註冊過,但兩邊用不同 ID 體系。做 fuzzy match:\nconst stages = [ // Stage 1: 姓名 + email + 電話三者匹配(最強) (u, c) =\u0026gt; normalizeName(u.real_name) === normalizeName(c.name) \u0026amp;\u0026amp; u.email === c.email \u0026amp;\u0026amp; u.phone_number === c.phone, // Stage 2: 姓名 + email + 生日 (u, c) =\u0026gt; normalizeName(u.real_name) === normalizeName(c.name) \u0026amp;\u0026amp; u.email === c.email \u0026amp;\u0026amp; u.birthday === c.birthday, // Stage 3-5: 兩兩組合 fallback... ]; // 同 key 多筆 user → 跳過不配(避免誤配對) 為什麼 5 階段? 真實資料髒:email 可能空、電話格式不一、姓名前面有來源系統加的 + 前綴。多階段 + 同 key 多筆跳過 = 寧可不配對也不錯配對。寧願留一筆未配對待 Phase 2 處理,也不要把「李○○ + 0912xxx789」配對到錯誤的 user 身上 — 一旦錯配,後面所有 historical document 都會掛到錯人頭上。\nPhase 2:為未配對 customers 建 pseudo user 某些 customers 從未用 SaaS app 註冊(線下走進門市的客人)。如果直接 skip 他們,未來業務查詢路徑會出現「有 customer 沒 user」的孤兒,partial cut-over 時就會 join 不出資料。解法是建 pseudo user:\nfor (const unpaired of unpairedCustomers) { await knex(\u0026#39;users\u0026#39;).insert({ email: `customer-${unpaired.dataId}@offline.example.local`, username: `customer-${unpaired.dataId}`, password: generateRandomPassword(), // 32 byte random,無人會用 real_name: normalizeName(unpaired.name), blocked: true, // 永久禁登入 user_type: \u0026#39;offline\u0026#39;, // + 13 個業務欄位 copy from customer }); } 關鍵設計:blocked = true + 假 email TLD .local 雙重保險,防止誤寄信、誤讓人登入。userType = 'offline' 讓 query 可以區分真會員 vs pseudo。\nPhase 3:backfill 歷史資料的 link 純 SQL,最簡單:\nINSERT INTO documents_customerUser_lnk (document_id, user_id) SELECT d.document_id, link.user_id FROM documents_customer_lnk d JOIN customers_users_lnk link ON link.customer_id = d.customer_id WHERE NOT EXISTS ( SELECT 1 FROM documents_customerUser_lnk WHERE document_id = d.document_id ); NOT EXISTS 保證冪等。Post-check 斷言 0 orphan:\nSELECT COUNT(*) FROM documents_customer_lnk d WHERE NOT EXISTS ( SELECT 1 FROM documents_customerUser_lnk WHERE document_id = d.document_id ); -- 預期 = 0;非 0 → 整個 migration abort + dump 問題 IDs 每個 phase 各自獨立 + 冪等:dry-run mode、互動式 yes 確認、跑兩次結果一樣。失敗時可以從中斷點重跑,不需要從頭來。\nLifecycle 持續同步:哪些欄位永不同步 外部 POS 系統一週 5 次匯入新 / 更新的客戶。Migration 不是一次性 — 需要持續同步。在 customers 表加 lifecycle hook:\nasync function syncToUser(customer) { try { const user = await findExistingLink(customer.id) ?? await findMatchingUser(customer) ?? await createPseudoUser(customer); await knex(\u0026#39;users\u0026#39;).where(\u0026#39;id\u0026#39;, user.id).update({ data_id: customer.data_id, address: customer.address, phone_number: customer.phone, // ... 13 個業務欄位「覆蓋」 // 但 NOT notes(見下) }); } catch (err) { log.error(`[Sync] FAILED ${customer.id}`, err); // 不 rethrow — sync 失敗不能 rollback customer 寫入 } } notes 欄位的「永不同步」規則 customers.notes 跟 users.notes 看起來是同名欄位,但語意不同:\ncustomers.notes 是外部 POS 系統那邊的客戶備註(門市人員填的) users.notes 是 SaaS 自己的後台會員備註(會計、客服填的) 如果無腦同步,會出現以下場景:\n6/01 customers.notes = \u0026#34;客人很怕痛\u0026#34; 6/15 一次性 migration 跑完,users.notes = \u0026#34;客人很怕痛\u0026#34; 6/20 SaaS 會計在後台加註:\u0026#34;客人很怕痛 / 6/18已退費 NT$200\u0026#34; 6/25 外部 POS 一週 5 次同步進來,customers.notes 仍是 \u0026#34;客人很怕痛\u0026#34; → 如果同步:users.notes 被蓋回 \u0026#34;客人很怕痛\u0026#34; → 會計加的「6/18已退費」消失 ❌ 解法:notes 欄位永不從 customers sync 到 users。Phase 1 時搬一次當初始值,之後 user.notes 完全屬於 SaaS 端。\n教訓:同名欄位不代表同語意。Migration 設計要逐欄位思考「資料的擁有者是誰」,不是「欄位名相同就 sync」。\nAPI 雙路徑共存:給呼叫端漸進遷移空間 外部呼叫端(前端、Mobile、第三方)都在用舊路徑:\nGET /api/documents?filters[customer][id][$eq]=12345 不可能一夜之間全部改完。設計 API 雙路徑:\nGET /api/documents?filters[customer][id][$eq]=12345 ← 舊(保留) GET /api/documents?filters[customerUser][id][$eq]=67890 ← 新(推薦) 兩個都 work、兩個 response shape 一致。呼叫端可以逐個改。\n寫入面同樣設計:create / update 收到任一邊(customer 或 customerUser),後端透過 link 表反查另一邊自動補齊:\nasync function autoFillCustomerPair(data, knex) { if (data.customerUser \u0026amp;\u0026amp; !data.customer) { const link = await findLinkByUserId(data.customerUser, knex); if (link) data.customer = link.customer_documentId; } else if (data.customer \u0026amp;\u0026amp; !data.customerUser) { const link = await findLinkByCustomerId(data.customer, knex); if (link) data.customerUser = link.user_documentId; } // 純 SaaS 會員(沒對應 customer)case:customer 欄位保持 NULL,合法 } 新前端只需傳 customerUser,舊前端只傳 customer,兩邊 dual-write 都成功。\nPartial cut-over:保留前端 contract,後端內部切讀 過渡期完成後,要把 read 路徑切到新表為主。但前端還沒準備好改 — 怎麼辦?\nPartial cut-over = 後端內部走新 link 表查詢,但 response shape 完全不變(前端「以為」沒事發生)。\n舊查詢路徑 LEFT JOIN documents_customer_lnk lnk LEFT JOIN customers c ON c.id = lnk.customer_id WHERE c.id = :customerId 新查詢路徑(內部多一個 hop) LEFT JOIN documents_customerUser_lnk dcul -- ← 新主路徑 LEFT JOIN customers_users_lnk uscl ON uscl.user_id = dcul.user_id LEFT JOIN customers c ON c.id = uscl.customer_id -- ← fallback hydrate WHERE c.id = :customerId 前端 query 不變,response 仍含 customer.id 給 UI 顯示。\nDISTINCT ON 處理一對多 link 實戰踩到的坑:少數 customer 在 link 表對應到多個 user(同名同電話的多帳號用戶)。新 join 後同一筆 document 出現 N 倍:\nSELECT * FROM ( SELECT DISTINCT ON (documents.id) documents.id, documents.title, c.id as customer_id, ... FROM documents LEFT JOIN documents_customerUser_lnk dcul ON dcul.document_id = documents.id LEFT JOIN customers_users_lnk uscl ON uscl.user_id = dcul.user_id LEFT JOIN customers c ON c.id = uscl.customer_id WHERE c.id = :customerId ORDER BY documents.id -- DISTINCT ON 要求第一個 ORDER BY 欄位 = 去重欄位 ) AS deduped ORDER BY visit_date DESC, created_at DESC; -- 外層再依正常條件排序 為什麼 DISTINCT ON 而不是 GROUP BY? GROUP BY 需要列出所有 select 欄位,join 來的 doctor / nurse 等欄位要 MAX() 包起來,容易寫錯。DISTINCT ON 只需指定去重欄位,PostgreSQL 自動取每組第一筆,配合 inner ORDER BY 控制「第一筆」是誰,乾淨且明確。\n實測 multi-user link 的客戶,raw join 出 26 筆 document,DISTINCT ON 去重後 13 筆,與舊路徑結果完全一致。\n踩坑列表 1. 加新 column 不會 backfill 既有 row 的 default Schema 寫 userType: { default: \u0026quot;member\u0026quot;, required: true },但 ORM 跑 ALTER TABLE 只加 column,不會把既有 row 填 default。既有 user 全是 NULL,後續 WHERE user_type IN ('member', 'offline') 全濾掉。\n解法:補一條 UPDATE users SET user_type='member' WHERE user_type IS NULL。寫進 migration plan 而非依賴 schema default。\n2. ORM 升版改 link table 命名 某 ORM 升版時改了 morph 表名:files_related_morphs → files_related_mph。Phase 1 第一次 execute 全 44 筆失敗。Schema migration 時別假設 ORM 自動生成的表名穩定,先 query 一次 information_schema.tables 確認。\n3. ORM 的 link table 沒有 metadata column 很多人會以為 users_customers_lnk 可以塞 match_type、created_at 之類 audit 欄位,但 ORM auto-generated link table 通常只有 3 個 column:id、user_id、customer_id。要塞 metadata 必須改成 explicit relation entity,多一層複雜度。實務上不值得,audit 寫到應用層 log 即可。\n4. Migration script 在 K8s pod memory 不足 Pod memory limit 1 GiB,主 ORM runtime 已用 ~800 MiB,跑 migration script 時 spawn 第二個 ORM instance → 撞牆 → 被 cgroup OOM kill,exit 137。\n解法:暫時 kubectl patch deploy 把 limit 提到 2 GiB,跑完改回 1 GiB。Prod 通常給 3 GiB+ 不會踩,但 STG 容易撞。\n5. Default sort 影響首頁顯示 新查詢預設 ORDER BY id ASC,page 1 都是早期測試帳號(id 4-100,全是空 row),看起來像「資料壞掉」。實際上是排序問題。改成 ORDER BY last_consumption DESC NULLS LAST, id ASC 後,page 1 自動是最近活躍的客戶。\n教訓:refactor query 時順手檢查 default sort 是否合理。靜默的 UX 退化最難 debug。\n6. Multi-link 重複造成 join 後筆數 N 倍 64 筆 customer 對應到多個 user(pre-existing data quirk,同名同電話的測試資料)。Phase 3 backfill 用 INSERT\u0026hellip;SELECT 從 link 表 join,每筆 document 出現多次。靠 DISTINCT ON 去重(前面已述)。\n「不砍任何東西」政策 理論上 cut-over 後應該收尾:砍舊欄位、砍 dual-write、砍 legacy API 路徑、砍 customers 表。實務上呢?\n老闆某天決定:不砍任何東西。所有 dual-write、雙欄位、雙路徑、customers 表全部永久保留。\n理由很現實:\n維度 砍掉 永久保留 程式碼乾淨度 ✅ 高 ❌ 低(兩條路徑並存) 維護成本(每筆寫入) ✅ 低 ❌ 多寫一個 link 表(毫秒級開銷) 移除風險(誰還在用 legacy) ⚠️ 高(外部呼叫端難盤點) ✅ 0(都 work) 與外部 POS 系統解耦 ⚠️ 需要 POS owner 配合 ✅ 不用 工程師心智負擔 ✅ 一條路徑 ❌ 看到兩個欄位會困惑 關鍵不是「技術上能不能砍」,是「砍掉之後外部 POS 系統匯入流程要不要重寫」。要重寫 = 牽涉外部廠商 = 排程不可控 = 無限期延後。乾脆永久 dual-write,用「每筆寫入多寫一個 link」的小成本換「不用協調外部廠商」的大價值。\n設計反思:技術文章常說「temporary 的東西最持久」。Dual-write 設計上是過渡方案,但實務常變成永久狀態。設計過渡方案時就該把「萬一變永久」也納入考量 — performance overhead 是否可接受?文件能不能說清楚為什麼有兩個欄位?新人 onboard 看到會不會困惑?\n結論 主表翻轉是個經典 schema migration 場景。3 個關鍵設計選擇:\nDual-write 過渡 vs Stop-the-world:選失敗代價最低的,不是成本最低的 Partial cut-over:read 跟 write 不同步切。後端先換內部路徑(response shape 不變),讓前端慢慢來 「不砍」政策:當外部依賴難協調時,永久共存的維護成本通常比一次性收尾的協調成本低 實作面的關鍵技巧:\n三階段 idempotent migration(搬欄位 / 建 pseudo entity / backfill)+ dry-run + 互動確認 DISTINCT ON 用 inner subquery 處理 multi-link 重複 每個欄位獨立思考「同步 vs 不同步」的擁有權問題(特別是 notes 這種多人寫入的欄位) ORM auto-generated 表名 / column 行為要驗證,不要假設 最後要記住:這類 migration 的成功不在「跑完 script」,在「過渡期間每一筆寫入都正確」。Dual-write 過渡看起來複雜,實際上是用結構性的安全網換取每一筆資料的正確性。\n","permalink":"https://peter-notes.com/posts/schema-pivot-dual-write-migration/","summary":"\u003ch2 id=\"引言當業務主表需要翻轉\"\u003e引言:當業務主表需要翻轉\u003c/h2\u003e\n\u003cp\u003e某個 SaaS 系統長期以「客戶資料表」為核心:所有訂單、文件、操作紀錄都用客戶 ID 當外鍵。但這個客戶表的資料來源是\u003cstrong\u003e外部 POS 系統匯入\u003c/strong\u003e,每天同步幾百筆,schema 由廠商定義。\u003c/p\u003e\n\u003cp\u003e業務發展後問題浮現:自己 SaaS 的會員表(users)才是真正的「人」 — 有登入、有偏好、有應用內行為。新功能(個人化推薦、訂閱管理、社交綁定)都需要以 users 為主軸。\u003c/p\u003e\n\u003cp\u003e於是有了一個經典的 schema migration 需求:\u003cstrong\u003e主表翻轉\u003c/strong\u003e(primary table pivot)。把業務邏輯的中心從 \u003ccode\u003ecustomers\u003c/code\u003e(外部 POS 匯入)轉到 \u003ccode\u003eusers\u003c/code\u003e(SaaS 自家會員),但歷史資料、新進資料、系統相依、回滾風險全部都要顧到。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"名詞解釋\"\u003e名詞解釋\u003c/h2\u003e\n\u003cp\u003e開始拆解之前,定義幾個會反覆出現的詞:\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e詞\u003c/th\u003e\n          \u003cth\u003e定義\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e主表翻轉\u003c/strong\u003e(Primary table pivot)\u003c/td\u003e\n          \u003ctd\u003e業務主要 entity 從表 A 改成表 B\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eDual-write\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e寫入時同時寫舊欄位 + 新欄位,回滾安全\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003ePartial cut-over\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e分階段切換,read 跟 write 不同步切\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eHard cut-over\u003c/strong\u003e(stop-the-world)\u003c/td\u003e\n          \u003ctd\u003e一次切完,downtime 短但 risk 大\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eBackfill\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e歷史資料補齊新欄位的 batch update\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eIdempotent migration\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e重跑無副作用,cron / retry 安全\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eDISTINCT ON\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003ePostgreSQL 專屬去重 syntax,搭 ORDER BY 取每組第一筆\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003ePseudo entity\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e為了統一查詢介面而建的「假」實體\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eLink table\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003eORM 多對多 join 表(\u003ccode\u003euser_id\u003c/code\u003e + \u003ccode\u003eentity_id\u003c/code\u003e)\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003chr\u003e\n\u003ch2 id=\"為什麼選-dual-write-而非-stop-the-world\"\u003e為什麼選 dual-write 而非 stop-the-world\u003c/h2\u003e\n\u003cp\u003e最直接的搬家方式是「stop-the-world cut-over」:選一個維護窗口,停寫入、跑 batch script、改完所有 reference、開機。但這個 SaaS 的條件不允許:\u003c/p\u003e","title":"Schema 主表翻轉的 dual-write 過渡:一場不能 stop-the-world 的搬家"},{"content":"問題現場：想做一份「能印」的網頁報表 需求很常見：後台要一頁會計報表（明細列表 + 合計列），會計同事要能直接從瀏覽器按列印、出 A4。沒人想為這種小需求引入 PDF library 或產 PDF 的後端服務 —— 瀏覽器列印就好。\n寫下去才發現坑滿地：頁碼從哪來？跨頁時表頭會不會消失？頁面外面那層 admin sidebar、左側選單會不會也跟著被印出來？螢幕上預覽是黑背景白字，印到紙上會變什麼樣？\n這些問題的答案幾乎都不在「常見 CSS 教學」裡，而是藏在一份很少人讀的 W3C 規範：CSS Paged Media Module。本文用一份實際做出來的會計報表為例，把這些技巧串起來。\n為什麼不選 PDF library？ 開發者第一個反射動作往往是「裝個 jsPDF / pdfmake 直接產 PDF」。在跳進那條路之前，先看清各方案的代價：\n方案 中文字型 排版精度 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，否則出現方塊或缺字。\n第二個雷是 html2canvas 路線的光柵化失真：先把 DOM 拍成 PNG 再塞進 PDF，文字變圖片、選不到、搜尋不到、放大就糊。對會計這種要逐筆比對的場景特別痛。\n那 PDF library 什麼時候該用？ 要產符合 PDF/A 規範的電子發票、要程式化簽章、要後端批次產月結對帳寄信。這幾類本來就不該由瀏覽器負責——應該交給後端 Puppeteer 或 WeasyPrint headless render，避免每個前端使用者各自承擔。\n「現場列印」型需求就用 CSS，下載式或自動化才需要 PDF library。本文剩下的篇幅都在講前者。\n心智模型：列印是一種「媒介」 CSS 從一開始就把「螢幕」和「紙張」當成不同的 media。比較多人寫過的是 @media print { ... }——把螢幕版的樣式切換成適合列印的版本（隱藏側邊欄、改字色、調間距）。但 @media print 只能管「規則切換」，它管不到紙張本身：邊界多寬、頁碼放哪、跨頁怎麼排，都不在它的權限內。\n真正控制紙張版面的工具是 @page：\n@page { size: A4 portrait; margin: 18mm 14mm 22mm 14mm; } 這段程式碼宣告：紙張 A4 直印、四周邊界各留多少 mm。注意單位用 mm 不是 px，因為紙是真實的物理介質，px 的概念不存在。\n@page 還有一個鮮為人知的能力：margin box。每一頁邊界外圍可以塞內容（頁碼、頁首、浮水印），這正是頁碼魔法的來源。\n頁碼：@page margin box + counter 不要用 JavaScript 算頁碼，瀏覽器列印引擎可以直接幫你做：\n@page { size: A4 portrait; margin: 18mm 14mm 22mm 14mm; @bottom-right { content: \u0026#34;第 \u0026#34; counter(page) \u0026#34; 頁 / 共 \u0026#34; counter(pages) \u0026#34; 頁\u0026#34;; font-size: 9pt; color: #555; } } counter(page) 是當前頁碼、counter(pages) 是總頁數，由分頁引擎在版面計算完成後注入。\n為什麼下緣 margin 要 22mm 不是 18mm？ margin box 內容是擠進 margin 區域裡的，太窄會被切。Chrome / Edge 的 Blink 引擎完整支援這個語法；Firefox 的 margin box 支援度差一截，如果你的使用者主要在 Firefox，可能要 fallback 到 JS 計算或乾脆放棄頁碼。對大多數企業內部後台（Chrome 為主），這幾行就解決了。\n跨頁表頭：\u0026lt;thead\u0026gt; 的隱藏天賦 報表跨多頁時，每一頁要重複「報表標題 + 期間 + 欄位列」 —— 不然第二頁打開沒前後文，誰看得懂哪欄是什麼。\n很多人第一次寫會把標題和期間放在 \u0026lt;table\u0026gt; 外面的 \u0026lt;div\u0026gt;、再單獨給表格 \u0026lt;thead\u0026gt;。結果列印時：表頭跨頁重複、但標題和期間只出現第一頁。\n關鍵：所有要跨頁重複的東西，都得在 \u0026lt;thead\u0026gt; 裡面。\n\u0026lt;table\u0026gt; \u0026lt;thead\u0026gt; \u0026lt;!-- 第一列：報表標題 + 期間（colSpan 跨滿全部欄位）--\u0026gt; \u0026lt;tr\u0026gt; \u0026lt;th colSpan=\u0026#34;6\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;report-title\u0026#34;\u0026gt;會計報表\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;report-meta\u0026#34;\u0026gt; 期間：2026-01-01 ~ 2026-04-30 ｜ 產生時間：2026-05-06 \u0026lt;/div\u0026gt; \u0026lt;/th\u0026gt; \u0026lt;/tr\u0026gt; \u0026lt;!-- 第二列：欄位標題 --\u0026gt; \u0026lt;tr\u0026gt; \u0026lt;th\u0026gt;訂單編號\u0026lt;/th\u0026gt; \u0026lt;th\u0026gt;成立日期\u0026lt;/th\u0026gt; \u0026lt;th\u0026gt;金額\u0026lt;/th\u0026gt; \u0026lt;th\u0026gt;付款狀態\u0026lt;/th\u0026gt; \u0026lt;th\u0026gt;發票號碼\u0026lt;/th\u0026gt; \u0026lt;th\u0026gt;發票日期\u0026lt;/th\u0026gt; \u0026lt;/tr\u0026gt; \u0026lt;/thead\u0026gt; \u0026lt;tbody\u0026gt;...\u0026lt;/tbody\u0026gt; \u0026lt;tfoot\u0026gt; \u0026lt;tr\u0026gt;\u0026lt;td colSpan=\u0026#34;6\u0026#34;\u0026gt;合計：N 筆 / 總金額 NT$ X\u0026lt;/td\u0026gt;\u0026lt;/tr\u0026gt; \u0026lt;/tfoot\u0026gt; \u0026lt;/table\u0026gt; @media print { thead { display: table-header-group; } tfoot { display: table-footer-group; } tr { page-break-inside: avoid; } } display: table-header-group 是 thead 的預設值，多數情況不寫也行 —— 但某些 CSS reset（例如 Bootstrap、Strapi admin、Tailwind 預設 preflight）會把 table 結構打亂，明確寫出來保險。page-break-inside: avoid 防止單一資料列被切到兩頁中間。\n列印隔離：visibility: hidden 的經典手法 這是最容易踩、最隱形的坑：你的列印頁不可能存在於真空，它一定包在某個應用框架（admin、Dashboard、SPA）裡。如果直接觸發 window.print()，整個 admin shell（左側 sidebar、上方 header、下方 footer）也會被印出來。\n解法是個古老但仍然最乾淨的 pattern：\n@media print { /* 1. 把所有東西藏起來 */ body * { visibility: hidden; } /* 2. 只露出列印容器 */ .acct-print, .acct-print * { visibility: visible; } /* 3. 把列印容器拉到頁面左上角，不要被原本的 layout 卡住 */ .acct-print { position: absolute; left: 0; top: 0; width: 100%; } } 為什麼用 visibility: hidden 不用 display: none？\n屬性 視覺 佔位 子孫元素能否覆寫 display: none 不顯示 不佔空間（layout 重排） ❌ 子孫一起消失，無法救回 visibility: hidden 不顯示 保留空間 ✅ 子孫可單獨改回 visible 我們需要「父元素藏、子元素露」這種挑出特定子樹的能力 —— display: none 會把整棵樹砍掉、子代沒救。visibility: hidden 才能搭配後面的 visibility: visible 把目標子樹挖出來。\nScreen vs Print 雙模式：一個容器兩種命 開新分頁顯示列印頁時，使用者會在螢幕上先看到那一頁（瀏覽器列印對話框是 modal，後面就是這個 preview 畫面）。如果你的 admin 是深色主題，列印容器會繼承深色文字 —— 但 CSS 給偶數列加了白底斑馬條紋 —— 白底白字，看不見任何東西。\n實戰修法：在容器裡強制反向配色，蓋掉 admin theme：\n.acct-print { background: #fff; color: #000; min-height: 100vh; } /* 連同所有後代，全部黑字 */ .acct-print, .acct-print * { color: #000 !important; } .acct-print tbody tr:nth-child(even) { background: #f7f7f7; } !important 在這裡是必要之惡 —— 你不知道 admin theme 用了多少個 selector 強加白色文字，與其逐一 override，不如用最大的權重把這條子樹整個切割出來。\n觸發列印 + 資料傳遞 最後一塊拼圖：怎麼把列表頁的資料送到列印頁？兩條路：\n// 列表頁：把資料塞進 localStorage 用一次性 key const key = crypto.randomUUID() localStorage.setItem(`accounting:print:${key}`, JSON.stringify(payload)) window.open(`/admin/accounting/print?key=${key}`, \u0026#39;_blank\u0026#39;) // 列印頁：讀完立刻清掉 const raw = localStorage.getItem(`accounting:print:${key}`) localStorage.removeItem(`accounting:print:${key}`) // 先清再用 const payload = JSON.parse(raw) // 等 DOM 渲染完再觸發列印 useEffect(() =\u0026gt; { setTimeout(() =\u0026gt; window.print(), 300) }, [payload]) 為什麼不用 query string？ 報表幾千筆資料 URL 放不下、瀏覽器網址列也會爆。為什麼是 localStorage 而不是 sessionStorage？ window.open 開新分頁時 sessionStorage 行為依賴 noopener flag —— 同源不帶 noopener 時新分頁會「複製」一份，但不是同步更新；要避這個邊角，localStorage 是更穩的選擇，反正讀完就刪。\n結語 可列印的網頁報表不需要 PDF library、不需要後端產檔，純 CSS 就能做出產品級體驗。關鍵是把 CSS Paged Media Module 的四個工具串起來：@page margin box 寫頁碼、\u0026lt;thead\u0026gt; 跨頁重複報表標題、visibility: hidden 隔離 admin shell、color: #000 !important 處理深色主題下的螢幕預覽。下次再有「能不能直接列印」的需求，這套組合拳很夠用。\n","permalink":"https://peter-notes.com/posts/printable-a4-report-css-guide/","summary":"\u003ch2 id=\"問題現場想做一份能印的網頁報表\"\u003e問題現場：想做一份「能印」的網頁報表\u003c/h2\u003e\n\u003cp\u003e需求很常見：後台要一頁會計報表（明細列表 + 合計列），會計同事要能直接從瀏覽器按列印、出 A4。沒人想為這種小需求引入 PDF library 或產 PDF 的後端服務 —— 瀏覽器列印就好。\u003c/p\u003e\n\u003cp\u003e寫下去才發現坑滿地：頁碼從哪來？跨頁時表頭會不會消失？頁面外面那層 admin sidebar、左側選單會不會也跟著被印出來？螢幕上預覽是黑背景白字，印到紙上會變什麼樣？\u003c/p\u003e\n\u003cp\u003e這些問題的答案幾乎都不在「常見 CSS 教學」裡，而是藏在一份很少人讀的 W3C 規範：\u003cstrong\u003eCSS Paged Media Module\u003c/strong\u003e。本文用一份實際做出來的會計報表為例，把這些技巧串起來。\u003c/p\u003e\n\u003ch2 id=\"為什麼不選-pdf-library\"\u003e為什麼不選 PDF library？\u003c/h2\u003e\n\u003cp\u003e開發者第一個反射動作往往是「裝個 jsPDF / pdfmake 直接產 PDF」。在跳進那條路之前，先看清各方案的代價：\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e方案\u003c/th\u003e\n          \u003cth\u003e中文字型\u003c/th\u003e\n          \u003cth\u003e排版精度\u003c/th\u003e\n          \u003cth\u003eBundle\u003c/th\u003e\n          \u003cth\u003e適用情境\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eCSS \u003ccode\u003e@page\u003c/code\u003e（本文）\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e✅ 系統字型\u003c/td\u003e\n          \u003ctd\u003e高（向量字）\u003c/td\u003e\n          \u003ctd\u003e0 KB\u003c/td\u003e\n          \u003ctd\u003e後台報表、現場列印、人工流程\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003ehtml2canvas + jsPDF\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e⚠️ 需嵌字型\u003c/td\u003e\n          \u003ctd\u003e中（光柵化）\u003c/td\u003e\n          \u003ctd\u003e~300 KB\u003c/td\u003e\n          \u003ctd\u003e簡單表單下載\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003epdfmake / React-PDF（向量 PDF）\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e⚠️ 需嵌字型\u003c/td\u003e\n          \u003ctd\u003e高\u003c/td\u003e\n          \u003ctd\u003e~500 KB+\u003c/td\u003e\n          \u003ctd\u003e給外部讀者的下載檔\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e後端 Puppeteer / WeasyPrint\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e✅ 系統字型\u003c/td\u003e\n          \u003ctd\u003e最高\u003c/td\u003e\n          \u003ctd\u003e0（前端）\u003c/td\u003e\n          \u003ctd\u003e批次寄信、加密簽章\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e\u003cstrong\u003e前端 PDF library 最大的痛點是中文字型\u003c/strong\u003e。系統列印免費借用作業系統字型；jsPDF 預設只內建 Helvetica，要中文不亂碼就得把整套思源黑體（壓縮後仍有 5-10 MB）打包進 bundle，否則出現方塊或缺字。\u003c/p\u003e","title":"可列印 A4 報表的 CSS 全攻略：頁碼、跨頁表頭、列印隔離"},{"content":"症狀：資料明明在，前端就是拿不到 一個 CMS 系統的文件列印預覽，每份文件 header 都固定顯示「上傳者：—」。DB 裡明明有資料，API 回 HTTP 200 OK，回應裡的其他欄位（日期、文件類型、附件）都對——唯獨 uploadedBy 是 null。\n沒錯誤、沒警告，伺服器 log 也乾淨。前端碰到 uploadedBy?.realName || '-' 就乖乖畫 dash。使用者以為這份文件沒指派擁有者，作者本人以為畫面壞了。大家都錯，但系統看起來好好的。\n這是最難纏的 bug——靜默失敗。\n先看 ORM 層在整個架構哪裡 要理解為什麼這類 bug 發生、也為什麼解法是「繞過 ORM」，先把架構分層看清楚：\n為什麼 bug 只會發生在 ORM 層？\n上面的業務邏輯只負責說「給我文件，順便帶上傳者」——它不知道要怎麼 JOIN。下面的 DB 只回應被問到的 SQL——你沒 SELECT 的欄位它不會主動給你。只有 ORM 層有權力決定「要翻譯成什麼 SQL」，也只有它遇到不會處理的組合時，會選擇「悄悄回你 null」而不是「拋錯給你看」。\n這就是為什麼靜默失敗只會出現在這一層。\n用 curl 逼它露餡 把 query 簡化到只剩 filter + populate 兩件事，一個一個比對：\n# A: 用 id filter → 關聯欄位變 null ❌ curl \u0026#34;$URL?filters[owner][id][\\$eq]=3807\u0026amp;populate[uploadedBy]=true\u0026#34; # { \u0026#34;uploadedBy\u0026#34;: null } # B: 用 documentId filter → 關聯正常回來 ✅ curl \u0026#34;$URL?filters[owner][documentId][\\$eq]=abc123\u0026amp;populate[uploadedBy]=true\u0026#34; # { \u0026#34;uploadedBy\u0026#34;: { \u0026#34;id\u0026#34;: 85, \u0026#34;realName\u0026#34;: \u0026#34;J. Chen\u0026#34; } } 同樣的 populate、同樣的文件集合，差別只在「怎麼辨認 owner」。這種事應該是 ORM 內部細節，對使用者不該有差。但就是有。\n這不是 Strapi 獨有：跨 ORM 的通病 寫到一半你可能以為這是 Strapi v5 特例。事實上，「關聯 filter 和 eager loading 放在一起時靜默壞掉」是 ORM 世界的共通坑：\nORM 關鍵字 典型陷阱 Strapi v5 populate filters[rel][id] + populate[rel] 讓關聯回 null Prisma include / select select 漏某欄位、或 include 與 select 互用，關聯被剝掉 TypeORM relations QueryBuilder 的 where + relations 混用，某些關聯不載入 Mongoose .populate() 後面接 .lean() 某些欄位就不被 populate SQLAlchemy joinedload 多層 joinedload + filter 產生笛卡兒積或被自動 unload Django prefetch_related 配合 Prefetch(queryset=...) 寫錯 queryset 就 silent 失敗 共通 pattern：ORM 在把 API query 翻成 SQL 的那一刻，遇到它內部認為「不合理」或「效能危險」的組合時，往往選擇「不幫你 JOIN」而非「拋錯」。結果你拿到半套資料，卻以為拿到全套。\n診斷手法：三招通用 不管哪家 ORM，靜默失敗卡住時都可以試這三招：\n1. 打開 SQL log，看它實際做了什麼 Strapi 可設 DATABASE_DEBUG=true、Prisma 的 log: ['query']、TypeORM 的 logging: true。多半會發現「它根本沒 JOIN 你要的那張表」——答案就在這。\n2. 對比差異 query 別一次改很多變數。固定 populate，只動 filter；或反之。差異出現那一刻就是線索。\n3. 直接查 DB 繞過 ORM，用 psql / mongo shell 驗資料是不是真的存在。如果 DB 有、API 沒——問題 100% 在 ORM 層，不用再懷疑使用者是不是忘記設欄位。\n通用解法：關鍵路徑「降一層」 當 ORM 變成黑盒子，最務實的做法是降到下一層。從上面那張架構圖看，就是從 ORM 層降到 Query Builder 層。\n以 Strapi 為例，自訂 controller 用 knex（Strapi 內建）明確 JOIN link table：\n// 不走 ORM populate，降一層用 Query Builder 自己 JOIN const uploaders = await knex(\u0026#39;reports_uploaded_by_lnk\u0026#39;) .leftJoin(\u0026#39;up_users\u0026#39;, \u0026#39;up_users.id\u0026#39;, \u0026#39;reports_uploaded_by_lnk.user_id\u0026#39;) .whereIn(\u0026#39;reports_uploaded_by_lnk.report_id\u0026#39;, reportIds) .select(\u0026#39;up_users.real_name\u0026#39;, \u0026#39;up_users.title\u0026#39;); 其他 ORM 都有類似 escape hatch，讓你跳到下一層：\nPrisma → prisma.$queryRaw 或 raw SQL tagged template TypeORM → getManager().query(...) 或 QueryRunner Mongoose → Model.aggregate([...]) 手寫 pipeline SQLAlchemy → session.execute(text('SELECT ...')) 寫的時候多打幾行字，換來每一筆 JOIN 都在你掌控之中。 在關鍵路徑（影響業務正確性的地方），這是值得的交易。\n反思：靜默降級是糟糕的預設 這個 bug 最讓人上火的不是它難修（實際修只要 20 行 knex），而是它應該在幾分鐘內被 server log 抓到，結果花了半天。\n好的框架應該在「我不確定能不能做到」時明確拋錯，而不是偷偷回 null。靜默降級會造成兩個後遺症：\n前端把「沒帶回來」當「沒設定」，業務邏輯跟著壞。 監控工具抓不到，只能靠使用者抱怨「欸怎麼變空白」才發現。 下次設計 API 或封裝 ORM 時，寧可拋一個 RelationLoadFailedError，也不要靜默回 null——讓錯誤有 trace，讓監控能叫。 這是框架設計者的責任，也是使用框架的人在看到 null 時要多想一秒的提醒。\n想看這個 bug 在 Strapi v5 上的具體 reproduction steps 和自訂 controller 完整實作，請看英文版：\n👉 Strapi v5\u0026rsquo;s Silent populate Failure with Relation Filters\n","permalink":"https://peter-notes.com/posts/orm-silent-populate-failure/","summary":"\u003ch2 id=\"症狀資料明明在前端就是拿不到\"\u003e症狀：資料明明在，前端就是拿不到\u003c/h2\u003e\n\u003cp\u003e一個 CMS 系統的文件列印預覽，每份文件 header 都固定顯示「上傳者：—」。DB 裡明明有資料，API 回 \u003ccode\u003eHTTP 200 OK\u003c/code\u003e，回應裡的其他欄位（日期、文件類型、附件）都對——\u003cstrong\u003e唯獨 \u003ccode\u003euploadedBy\u003c/code\u003e 是 \u003ccode\u003enull\u003c/code\u003e\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e沒錯誤、沒警告，伺服器 log 也乾淨。前端碰到 \u003ccode\u003euploadedBy?.realName || '-'\u003c/code\u003e 就乖乖畫 dash。使用者以為這份文件沒指派擁有者，作者本人以為畫面壞了。大家都錯，但系統看起來好好的。\u003c/p\u003e\n\u003cp\u003e這是最難纏的 bug——\u003cstrong\u003e靜默失敗\u003c/strong\u003e。\u003c/p\u003e\n\u003ch2 id=\"先看-orm-層在整個架構哪裡\"\u003e先看 ORM 層在整個架構哪裡\u003c/h2\u003e\n\u003cp\u003e要理解為什麼這類 bug 發生、也為什麼解法是「繞過 ORM」，先把架構分層看清楚：\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/orm-silent-populate-failure-diagram-2372fac1.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e\u003cstrong\u003e為什麼 bug 只會發生在 ORM 層？\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e上面的業務邏輯只負責說「給我文件，順便帶上傳者」——它不知道要怎麼 JOIN。下面的 DB 只回應被問到的 SQL——你沒 SELECT 的欄位它不會主動給你。\u003cstrong\u003e只有 ORM 層有權力決定「要翻譯成什麼 SQL」\u003c/strong\u003e，也只有它遇到不會處理的組合時，會選擇「悄悄回你 \u003ccode\u003enull\u003c/code\u003e」而不是「拋錯給你看」。\u003c/p\u003e\n\u003cp\u003e這就是為什麼\u003cstrong\u003e靜默失敗只會出現在這一層\u003c/strong\u003e。\u003c/p\u003e\n\u003ch2 id=\"用-curl-逼它露餡\"\u003e用 curl 逼它露餡\u003c/h2\u003e\n\u003cp\u003e把 query 簡化到只剩 filter + populate 兩件事，一個一個比對：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# A: 用 id filter → 關聯欄位變 null ❌\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecurl \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$URL\u003c/span\u003e\u003cspan class=\"s2\"\u003e?filters[owner][id][\\$eq]=3807\u0026amp;populate[uploadedBy]=true\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# { \u0026#34;uploadedBy\u0026#34;: null }\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# B: 用 documentId filter → 關聯正常回來 ✅\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecurl \u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"nv\"\u003e$URL\u003c/span\u003e\u003cspan class=\"s2\"\u003e?filters[owner][documentId][\\$eq]=abc123\u0026amp;populate[uploadedBy]=true\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# { \u0026#34;uploadedBy\u0026#34;: { \u0026#34;id\u0026#34;: 85, \u0026#34;realName\u0026#34;: \u0026#34;J. Chen\u0026#34; } }\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e同樣的 populate、同樣的文件集合，差別只在「怎麼辨認 owner」。這種事應該是 ORM 內部細節，對使用者不該有差。但就是有。\u003c/p\u003e","title":"ORM 在騙你：當 populate / include 悄悄失效"},{"content":"Symptom: A Relation Everyone Believes Is Empty A document management print preview kept rendering \u0026ldquo;Uploader: —\u0026rdquo; 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.\nUsers assumed reports had no uploader assigned. Authors assumed the UI was broken. Nobody suspected the API, because it was perfectly silent.\nWhere the Bug Lives in the Stack Before the curl session, it helps to fix where the failure happens:\nOnly the ORM layer translates populate[uploadedBy]=true into actual JOINs. The DB only answers the SQL it gets, and the controller layer doesn\u0026rsquo;t know which tables exist. If a relation comes back empty, the ORM is the only suspect.\nThe Bug, in One Sentence populate[uploadedBy]=true works on its own. filters[owner][id][$eq]=N works on its own. Combine them, and uploadedBy is silently dropped.\nReproducing It Two requests, only the filter shape differs:\n# A — filter by numeric id + populate → null (BROKEN) curl \u0026#34;$API/api/reports?\\ filters[owner][id][\\$eq]=3807\u0026amp;\\ populate[uploadedBy]=true\u0026#34; # =\u0026gt; { \u0026#34;uploadedBy\u0026#34;: null } # B — filter by documentId + populate → works curl \u0026#34;$API/api/reports?\\ filters[owner][documentId][\\$eq]=m5cixrk1ekf3qj6gjb9zfijl\u0026amp;\\ populate[uploadedBy]=true\u0026#34; # =\u0026gt; { \u0026#34;uploadedBy\u0026#34;: { \u0026#34;id\u0026#34;: 85, \u0026#34;realName\u0026#34;: \u0026#34;J. Chen\u0026#34; } } Both queries hit the same report set. Both ask for the same populate. The only difference is how the owner is identified — which should be an internal detail, not a feature gate. Yet:\nQuery Shape uploadedBy Status Error in Logs filters[owner][id] + populate null ❌ 200 OK none filters[owner][documentId] + populate populated ✅ 200 OK none populate only (no filter) populated ✅ 200 OK none If Strapi had returned a 500 or even a 200 with { error: \u0026quot;populate skipped\u0026quot; }, this would have been a 30-second fix. Silently dropping the relation turns it into a multi-hour spelunking exercise.\nWhy It\u0026rsquo;s So Hard to Catch No stack trace, no thrown exception No error field on the response Every other property of the record is correct (date, type, files…) Front-end code that does uploadedBy?.realName simply renders - You end up doubting the data, the front-end, the auth — anything but the API. Only when you compare two near-identical queries side-by-side does the pattern surface.\nIn our case, three things made the diagnosis painfully slow:\nThe data was real. Direct psql queries against reports_uploaded_by_lnk showed the link rows were there. So we wasted time hunting for \u0026ldquo;missing data\u0026rdquo; that was never missing. One developer\u0026rsquo;s seed data worked. A teammate using the Strapi admin to seed a few records would happen to filter by documentId while debugging, and the relation appeared. The bug \u0026ldquo;didn\u0026rsquo;t reproduce\u0026rdquo; until we wrote the exact same filters[id] query the production frontend was generating. No tests covered it. The relation appears correctly in findOne(documentId) calls, which is what most automated tests cover. The list-with-filter shape is what the print preview screen actually uses, and it had no integration test of its own. This is the shape of every silent-failure bug: the symptom is gentle, the data looks plausible, and the \u0026ldquo;happy path\u0026rdquo; tests still pass.\nWorkaround: A knex-Based Custom find Controller The fix is to drop one layer — from Strapi\u0026rsquo;s ORM down to the knex query builder it ships with — and own the JOIN explicitly:\n// src/api/report/controllers/report.ts async find(ctx) { const ownerId = (ctx.query as any)?.filters?.owner?.id?.$eq; // If no owner filter, let Strapi handle it normally if (!ownerId) return super.find(ctx); const knex = strapi.db.connection; // 1. Fetch the reports for that owner via the link table const reports = await knex(\u0026#39;reports\u0026#39;) .leftJoin(\u0026#39;reports_owner_lnk as ol\u0026#39;, \u0026#39;ol.report_id\u0026#39;, \u0026#39;reports.id\u0026#39;) .where(\u0026#39;ol.owner_id\u0026#39;, ownerId) .select(\u0026#39;reports.*\u0026#39;); // 2. Pull uploadedBy with a dedicated LEFT JOIN on its link table — // this is the part Strapi was silently skipping const reportIds = reports.map(r =\u0026gt; r.id); const uploaders = await knex(\u0026#39;reports_uploaded_by_lnk\u0026#39;) .leftJoin(\u0026#39;up_users\u0026#39;, \u0026#39;up_users.id\u0026#39;, \u0026#39;reports_uploaded_by_lnk.user_id\u0026#39;) .whereIn(\u0026#39;reports_uploaded_by_lnk.report_id\u0026#39;, reportIds) .select( \u0026#39;reports_uploaded_by_lnk.report_id\u0026#39;, \u0026#39;up_users.id\u0026#39;, \u0026#39;up_users.real_name\u0026#39;, \u0026#39;up_users.title\u0026#39;, ); // 3. Stitch the response together explicitly — no silent drops const byReport = Object.fromEntries(uploaders.map(u =\u0026gt; [u.report_id, u])); return { data: reports.map(r =\u0026gt; ({ ...r, uploadedBy: byReport[r.id] ?? null })), }; } Three things worth noting:\nIt only intercepts the broken query shape. When filters.owner.id is absent, we fall through to super.find(ctx) so we don\u0026rsquo;t regress every other caller. Link tables are the source of truth. Strapi v5 uses *_lnk tables for every relation; joining them directly is exactly as correct as populate would have been — just spelled out. null becomes intentional, not accidental. If byReport[r.id] is missing, that means we genuinely didn\u0026rsquo;t find an uploader, and the explicit ?? null documents that. This is not officially documented as a Strapi bug, and Strapi v5\u0026rsquo;s populate API doesn\u0026rsquo;t warn about the interaction with id-based relation filters. Until upstream fixes it, owning the JOIN is the only deterministic option.\nAlternatives Considered Before committing to the custom controller, three other paths got ruled out:\nSwitch every caller to documentId filters. Tempting, but it leaks Strapi\u0026rsquo;s internal id model into every frontend that consumes this API. Some callers (mobile apps with cached numeric ids) couldn\u0026rsquo;t migrate cleanly anyway. Patch the upstream entity service. Possible in theory, but Strapi\u0026rsquo;s populate code path is non-trivial and any fork becomes a maintenance burden on every minor upgrade. Add a post-fetch hydration step that calls /api/users/:id per report. Functionally equivalent, but turns one query into N+1 queries — bad for any owner with more than a handful of records, and re-introduces a different silent-failure mode if any of the user lookups 401s. The knex approach trades some controller verbosity for one extra LEFT JOIN. That\u0026rsquo;s the right trade for this codebase.\nThe General Lesson When an ORM returns null instead of throwing on an ambiguous query, silence becomes a bug amplifier. A relation field you asked for that comes back empty is no longer just data — it\u0026rsquo;s a signal worth investigating.\nIf your hot path depends on a particular relation loading, consider owning that join in raw SQL or a query builder. A slightly more verbose controller is cheap insurance against a silent regression.\nFor a language-agnostic version of this anti-pattern across Prisma, TypeORM, Mongoose, SQLAlchemy, and Django, plus a more general framing of why ORMs sometimes silently drop relations:\n👉 ORM 在騙你：當 populate / include 悄悄失效 (zh-TW)\n","permalink":"https://peter-notes.com/posts/strapi-v5-populate-silent-failure-with-relation-filters/","summary":"\u003ch2 id=\"symptom-a-relation-everyone-believes-is-empty\"\u003eSymptom: A Relation Everyone Believes Is Empty\u003c/h2\u003e\n\u003cp\u003eA document management print preview kept rendering \u0026ldquo;Uploader: —\u0026rdquo; for every report. The database had the data. The API returned \u003ccode\u003eHTTP 200 OK\u003c/code\u003e. Yet the \u003ccode\u003euploadedBy\u003c/code\u003e relation came back as \u003ccode\u003enull\u003c/code\u003e, with no error or warning anywhere.\u003c/p\u003e\n\u003cp\u003eUsers assumed reports had no uploader assigned. Authors assumed the UI was broken. Nobody suspected the API, because it was perfectly silent.\u003c/p\u003e\n\u003ch2 id=\"where-the-bug-lives-in-the-stack\"\u003eWhere the Bug Lives in the Stack\u003c/h2\u003e\n\u003cp\u003eBefore the curl session, it helps to fix where the failure happens:\u003c/p\u003e","title":"Strapi v5's Silent populate Failure with Relation Filters"},{"content":"問題現場：一個「成功」卻收不到信的忘記密碼 某個週一下午，QA 回報：在一個 Flutter + Strapi 架構的 App 上，用測試帳號 testuser@example.com 點「忘記密碼」，畫面跳出 Success: Reset password link sent successfully on your email account.，但信箱（包含垃圾郵件匣）一封信都沒有。\n直覺先排除幾個明顯可能：SMTP 設定壞掉、用戶被 block、信件被 Gmail 擋。但真正的答案比這些都有趣 — 這不是 bug，是 Strapi 一個刻意的安全設計，只是 App 前端把它翻譯錯了。\nkubectl 診斷：20ms 與 800ms 的兩種人生 進 Strapi pod 撈 log，找 forgot-password 請求：\nkubectl logs -n default strapi-prod-xxxxxxxxx-xxxxx --since=6h \\ | grep -E \u0026#39;forgot-password|Reset password URL\u0026#39; 抽出的關鍵幾行：\n04: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 倍？\n因為 800ms 那條走完整個流程：查 DB、產 JWT token、寫回 user 欄位、呼叫 SMTP 寄信。20ms 那條只做到「查 DB → 找不到 → 直接回 200」。對不存在的 email，Strapi 選擇安靜地什麼都不做，但仍然回 200 Success。\n兩個分支回傳的 HTTP status 與 body 完全一致，差別只在時間 — 這是刻意的設計（稍後解釋原因）。\nEmail Enumeration 的價值：為什麼一份名單值錢 「找不到就直接回 404 不就好了？」這正是 email enumeration attack 的攻擊入口。\n假設 API 會誠實告訴你「此 email 未註冊」，攻擊者可以拿一份幾億筆的外洩 email 清單，逐筆打 /api/auth/forgot-password，從回應差異篩出「這個網站有帳號的人」。這份精選名單的殺傷力，比原本的大雜燴清單高一個數量級。它直接變成兩種攻擊的油料：\nPhishing（釣魚攻擊）：攻擊者拿著已確認的用戶名單，可以寄出上下文精準的假信 — 「你的 XX App 帳號異常，請立即點此重設密碼」。因為攻擊者已經知道收件人是真用戶，話術可以具體到讓人放下戒心。\nCredential Stuffing（撞庫攻擊）：許多人跨站重複使用同一組密碼。當 X 網站外洩「email + password」清單，攻擊者會拿去 Y 網站逐一嘗試登入。撞庫效率取決於「清單裡在 Y 網站有帳號的比例」。Enumeration 把這個比例從「全人口」升級到「已註冊用戶」，命中率可能差 10 倍。\n攻擊者的工具鏈：不是腳本小子的手工活 以下工具都是 OWASP、Akamai State of the Internet Report、Krebs on Security 等公開資安來源長期追蹤的對象。很多本身有合法的紅隊演練或安全意識訓練用途，只是同樣會被濫用。了解它們的運作是防禦的前提。\nPhishing：從 Gophish 到 Evilginx2 的技術演進 Gophish — 可管理的釣魚活動平台\nGophish 是開源的釣魚活動管理系統，原始定位是企業做員工安全意識訓練。有了 enumeration 得到的名單後，攻擊者只要透過 API 灌入目標、套用假信範本、排程發送：\n# 匯入目標名單 curl -X POST https://gophish.local/api/groups/ \\ -H \u0026#34;Authorization: Bearer API_KEY\u0026#34; \\ -d \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;confirmed-users\u0026#34;,\u0026#34;targets\u0026#34;:[ {\u0026#34;email\u0026#34;:\u0026#34;victim1@example.com\u0026#34;,\u0026#34;first_name\u0026#34;:\u0026#34;Alice\u0026#34;}, {\u0026#34;email\u0026#34;:\u0026#34;victim2@example.com\u0026#34;,\u0026#34;first_name\u0026#34;:\u0026#34;Bob\u0026#34;} ]}\u0026#39; # 建立 landing page（偽裝的登入頁） curl -X POST https://gophish.local/api/pages/ \\ -d \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;reset-page\u0026#34;,\u0026#34;html\u0026#34;:\u0026#34;\u0026lt;form action=\\\u0026#34;/track\\\u0026#34;\u0026gt;...\u0026lt;/form\u0026gt;\u0026#34;, \u0026#34;capture_credentials\u0026#34;:true,\u0026#34;capture_passwords\u0026#34;:true}\u0026#39; Gophish 會自動嵌入追蹤 token 到每封信的連結，記錄：誰打開信、誰點了連結、誰輸入帳密。這就是釣魚的工業化：不再是一封一封手工寄信，而是清單進 → dashboard 出。\nEvilginx2 — 繞過 2FA 的反向代理\n光偷密碼已經過時，現代帳號多半開 2FA。Evilginx2 解決了這件事。它是一個反向代理式釣魚框架：受害者以為自己在與真網站互動，實際上瀏覽器連到攻擊者的代理，代理再即時把請求轉到真網站。整條流量對受害者看起來都正確，包括 TOTP 驗證頁面、SMS 驗證碼畫面，因為它們真的就是真網站回應的內容。\n關鍵是：當受害者完成 2FA，真網站會回一個已驗證的 session cookie 給代理。Evilginx2 在中間攔下這個 cookie，攻擊者直接載入到自己的瀏覽器就能登入受害者帳號 — TOTP、SMS、App authenticator 全部失效，因為整個 session 被接管了。\nEvilginx2 用「phishlet」定義目標網站的釣魚邏輯（哪些 cookie 要攔、哪些 URL 要 rewrite）。社群有公開的 phishlet repository 涵蓋 Microsoft 365、Google、Okta、LinkedIn 等，幾小時就能架起一個能騙過多因素驗證的 proxy。\n這是為什麼 FIDO2 / passkey 比 TOTP 重要 — 它們對特定 domain 做密碼學綁定，MITM proxy 冒不出來。\nPhishing-as-a-Service：訂閱制犯罪經濟\n更值得注意的趨勢是釣魚工具的 SaaS 化。Caffeine（Mandiant 2022 年曝光）、EvilProxy（Proofpoint 2022）、16shop（Group-IB 與 INTERPOL 2023 合作端掉）都是典型 PhaaS：攻擊者只要刷 $200-$2000 月費，平台提供完整的 landing page 模板、反向代理、受害者 dashboard、反偵測模組（偵測是否來自資安公司 IP 就不顯示釣魚頁）。\n攻擊門檻被壓到極低：不再需要寫程式或懂資安，只需要一份目標名單。這回到為什麼 enumeration 這麼重要 — 它是整個犯罪產業鏈最上游的原料。\nCredential Stuffing：自動化憑證測試的工業 OpenBullet 2 — Config 驅動的撞庫引擎\nOpenBullet 2 是公開的自動化憑證測試框架，同樣有合法的企業內部測試用途。地下論壇流通的是它的「config 檔」 — 描述特定目標網站的登入流程。一個典型 config 邏輯（以 YAML 表示）：\n# 目標 endpoint TARGET: url: https://app.example.com/api/auth/local method: POST headers: Content-Type: application/json User-Agent: \u0026lt;RANDOM_UA\u0026gt; # 每次輪替 payload: \u0026#39;{\u0026#34;identifier\u0026#34;:\u0026#34;\u0026lt;USER\u0026gt;\u0026#34;,\u0026#34;password\u0026#34;:\u0026#34;\u0026lt;PASS\u0026gt;\u0026#34;}\u0026#39; # 輸入：combolist 每行 email:password INPUT: combo.txt # 成功條件 SUCCESS_IF: - status == 200 - body contains \u0026#34;jwt\u0026#34; - body contains \u0026#34;user.id\u0026#34; # 失敗條件（區分 ban vs 密碼錯） BAN_IF: - status == 429 - status == 403 FAIL_IF: - status == 400 - body contains \u0026#34;Invalid\u0026#34; # 代理輪替 + CAPTCHA bypass PROXY: residential-pool.txt CAPTCHA: 2captcha_api_key: \u0026lt;KEY\u0026gt; # 成功後要做的事（取得個資 → 提高憑證價值） ON_SUCCESS: - capture: user.email, user.name, user.creditLastFour Config 決定這套框架有多危險。成熟的 config 能區分「帳號被鎖」與「密碼錯」、能辨識 CAPTCHA 挑戰並自動丟給 solver、成功後還會抓取帳戶內的個資（姓名、部分信用卡號、餘額）讓憑證在地下市場賣更高價。\nSentry MBA 是更早期的同類工具，至今仍在論壇流通。差別在 UI 年代感更強，但核心機制相同。\n撞庫的經濟學：為什麼「看起來不可能」變成日常 撞庫不是「有人很無聊試 100 萬個密碼」，它是有 ROI 計算的生意。拆解成本結構：\nCAPTCHA solver：2Captcha、Anti-Captcha、CapSolver 這類服務，用人工 + ML 組合解決 reCAPTCHA / hCaptcha。每 1000 題約 $1 美金（reCAPTCHA v2）到 $3（reCAPTCHA v3），API 回應時間 10-30 秒。\n住宅代理（residential proxy）：IP 從真實家庭網路來，不是雲端 IP，比較不容易被 rate limit。Bright Data、Oxylabs、Smartproxy 這類服務每 GB 約 $3-$15。一次撞庫請求約 2-5 KB，1 GB 可以跑約 20-50 萬次嘗試。\n算一筆帳：跑 100 萬次撞庫（假設只有 30% 觸發 CAPTCHA）：\nCAPTCHA 成本：30 萬題 × $1/1000 = $300 Proxy 成本：100 萬 × 3 KB ÷ 1000 GB × $5 = $15 基礎設施（VPS + OpenBullet）：$20 總成本約 $335 產出：即使命中率只有 0.1%（業界撞庫平均落在 0.1-2%），也能拿到 1000 組有效憑證。地下市場的憑證價格：一般電商帳號 $1-5、串流媒體（Netflix/Disney+）$3-10、金融類 $20-100。即使只賣最低價，ROI 仍然是 3-10 倍。\n這解釋了為什麼以下事件會發生：\nDisney+（2019 年 11 月）：服務上線 24 小時內，大量帳號就出現在地下論壇以 $3-11 美金販售（BleepingComputer 報導）。Disney+ 本身沒被駭，是攻擊者拿其他服務外洩的 combolist 撞出來的。 Zoom（2020 年 4 月）：超過 50 萬筆帳號在暗網流通（Cyble 報告），同樣是撞庫成果。 PayPal（2022 年 12 月）：官方揭露約 34,942 個帳號被撞庫入侵（PayPal 通知），攻擊者存取了用戶姓名、地址、社會安全號碼等。 這些案例的共同點：被攻擊的公司本身沒有外洩資料。他們只是沒有足夠強的撞庫防禦，而攻擊者已經透過別處的 breach 拿到 combolist。\n防禦對應表：攻擊工具 → 反制措施 攻擊鏈環節 主要工具 防禦層 目標名單建立 利用 enumeration 篩選 ✅ Anti-enumeration：API + UX 對回應同樣沉默 假信發送 Gophish、PhaaS ✅ SPF / DKIM / DMARC、郵件來源驗證、用戶教育 偷 session（繞 2FA） Evilginx2、EvilProxy ✅ FIDO2 / Passkey（對 domain 做密碼學綁定，proxy 騙不出來） 自動化登入測試 OpenBullet、Sentry MBA ✅ Rate limit + 異常登入偵測 + device fingerprint 已外洩密碼撞庫 Combolist ✅ 註冊/改密碼時呼叫 Pwned Passwords API，擋掉已知洩漏的密碼 CAPTCHA 繞過 2Captcha、CapSolver ✅ 行為分析型挑戰（reCAPTCHA v3、Arkose Labs）而非靜態圖形驗證碼 Anti-enumeration 是這張表最前面的那一格 — 掐斷這個源頭，後面每一層的攻擊成本都會被顯著拉高。\n問題其實在 App 文案 既然 API 層必須保持沉默，前端的文案就不能擅自斷言「信件已寄出」。原本的實作是這樣：\nif (response.ok) { Get.snackbar( \u0026#39;Success\u0026#39;, \u0026#39;Reset password link sent successfully on your email account.\u0026#39;, ); } 這句話把 HTTP 200 翻譯成「信件已寄出」— 是一個資訊揭露錯誤，把 Strapi 刻意隱藏的資訊（email 是否存在）從 UX 反推出來。如果用戶在此看到 Success 卻收不到信，邏輯上只剩兩個可能：email 不存在或 SMTP 故障。攻擊者同樣能從這個看似正常的回應，配合多個測試帳號的行為差異，重建 enumeration。\n正確做法是讓文案配合 API 的曖昧性：\nGet.snackbar( LocalizationService.translate(\u0026#39;forgotPasswordInfoTitle\u0026#39;), LocalizationService.translate(\u0026#39;forgotPasswordInfoMessage\u0026#39;), ); i18n key 的內容改為條件句：\n{ \u0026#34;forgotPasswordInfoTitle\u0026#34;: \u0026#34;提示\u0026#34;, \u0026#34;forgotPasswordInfoMessage\u0026#34;: \u0026#34;若此信箱已註冊，我們將寄送重設密碼連結，請至信箱（包含垃圾郵件匣）查收。\u0026#34; } Title 從 Success 改成中性的「提示」，因為 HTTP 200 不等於成功寄出 — 訊息應該對用戶和攻擊者同樣模糊。加上「包含垃圾郵件匣」對真有寄信的情境也更實用。\n結語：威脅模型是一條鏈，前後端共同守護 這個 debug 過程最值得記下來的原則是：後端刻意隱藏的資訊，前端不能用 UX 揭露回來。Strapi 用 200 回應與耗時差距壓平了「存在 vs 不存在」的訊號，但只要前端訊息太具體（「link sent to your email」），enumeration 的窗口就又被打開。\nGophish、Evilginx2、OpenBullet、PhaaS 訂閱服務 — 這整套攻擊鏈的威脅模型，建立在「能拿到一份高品質已註冊清單」這個前提上。Anti-enumeration 看起來只是改一行 UI 文案，實際上擋掉的是整個犯罪產業鏈最上游的原料供應。撞庫攻擊 $335 的成本就能產 3-10 倍 ROI 的現實，意味著這不是「會不會被攻擊」的問題，而是「攻擊成本能被拉到多高」。\n下次遇到類似的「API 看起來正常、但行為不合預期」的現象，看 log 的時間分佈常常比看 HTTP status 更有線索。20ms 與 800ms 的差距，已經告訴你真相了。\n","permalink":"https://peter-notes.com/posts/strapi-forgot-password-anti-enumeration/","summary":"\u003ch2 id=\"問題現場一個成功卻收不到信的忘記密碼\"\u003e問題現場：一個「成功」卻收不到信的忘記密碼\u003c/h2\u003e\n\u003cp\u003e某個週一下午，QA 回報：在一個 Flutter + Strapi 架構的 App 上，用測試帳號 \u003ccode\u003etestuser@example.com\u003c/code\u003e 點「忘記密碼」，畫面跳出 \u003ccode\u003eSuccess: Reset password link sent successfully on your email account.\u003c/code\u003e，但信箱（包含垃圾郵件匣）一封信都沒有。\u003c/p\u003e\n\u003cp\u003e直覺先排除幾個明顯可能：SMTP 設定壞掉、用戶被 block、信件被 Gmail 擋。但真正的答案比這些都有趣 — 這不是 bug，是 Strapi 一個\u003cstrong\u003e刻意的安全設計\u003c/strong\u003e，只是 App 前端把它翻譯錯了。\u003c/p\u003e\n\u003ch2 id=\"kubectl-診斷20ms-與-800ms-的兩種人生\"\u003ekubectl 診斷：20ms 與 800ms 的兩種人生\u003c/h2\u003e\n\u003cp\u003e進 Strapi pod 撈 log，找 \u003ccode\u003eforgot-password\u003c/code\u003e 請求：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ekubectl logs -n default strapi-prod-xxxxxxxxx-xxxxx --since\u003cspan class=\"o\"\u003e=\u003c/span\u003e6h \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  \u003cspan class=\"p\"\u003e|\u003c/span\u003e grep -E \u003cspan class=\"s1\"\u003e\u0026#39;forgot-password|Reset password URL\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e抽出的關鍵幾行：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e04:04:52 info: Reset password URL generated: https://example.com/...\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e04:04:53 http: POST /api/auth/forgot-password (948 ms) 200\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e05:15:44 http: POST /api/auth/forgot-password (20 ms) 200\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e05:16:53 info: Reset password URL generated: https://example.com/...\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e05:16:54 http: POST /api/auth/forgot-password (664 ms) 200\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e05:17:30 http: POST /api/auth/forgot-password (26 ms) 200\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e一眼看出兩種節奏：\u003cstrong\u003e800ms 級別的請求伴隨「Reset password URL generated」log，而 20-30ms 的請求則完全沒有\u003c/strong\u003e。同一支 API，為什麼耗時差 30 倍？\u003c/p\u003e","title":"Strapi 忘記密碼的安靜回應：Anti-Enumeration、Phishing-as-a-Service 與撞庫經濟學"},{"content":"症狀：選完手機照片，畫面卻還是貼圖 使用者回報的步驟很簡單：點頭像 → 選「從手機圖庫」→ 選張照片 → 儲存。跳回會員中心，頭像還是原本的系統貼圖。\n再點進去編輯頁，看到的又是手機照片。明明存成功了，為什麼 UI 顯示不一致？\n這個 bug 表面上是前端畫面問題，但用 log 逼出來的真相，藏在三層之外——包括一個會在幾乎所有 admin-panel 型後端都出現的經典陷阱。\n偵錯：跟著 log 走，別信錯誤訊息 第一步是打開前端 log 重現流程。關鍵幾行：\nStickerService -\u0026gt; Deactivating all stickers StickerService -\u0026gt; Error: status code of 403 StickerService -\u0026gt; Error Response: {status: 403, message: Forbidden} StickerService -\u0026gt; Deactivate all failed: You do not own this sticker 最後這句「You do not own this sticker」幾乎讓我停在錯誤方向——去查使用者到底擁有哪幾個貼圖。但翻前端程式碼才發現：那不是後端回的訊息，是前端 _handleDioError 把「403 Forbidden」硬翻譯成這句。\n**這是第一個教訓：HTTP 狀態碼與文字訊息之間的語意對應是會說謊的。**真正的錯誤只有一個字——Forbidden。跟 ownership 無關，純粹是權限問題。\n第一層根因：Admin Panel 的權限設定漂移 翻 Strapi admin → Settings → Users \u0026amp; Permissions → Roles → Authenticated → Sticker section，發現 deactivateAll 這支 route 的權限沒勾。\n其他三支 endpoint（available、setAvatar、purchase）都開著，只有這一支漏掉。比對程式碼，這是幾週前新增的 route，開發時在 dev 環境勾過，部署到 staging 卻沒同步。\n這是 admin-panel 型後端的典型陷阱：權限配置不在 git 裡，跟著資料庫走。 新增 route 寫在程式碼、會進 code review、會 merge——但那條資料庫裡的權限記錄，沒人顧得到。\n第二層破壞：前端 Stale Cache 放大影響 為什麼 403 沒被注意到？因為前端的寫法是這樣：\nFuture\u0026lt;bool\u0026gt; deactivateAllStickers() async { final response = await _dio.put(\u0026#39;/api/stickers/deactivate-all\u0026#39;); if (response.statusCode == 200) { await fetchStickers(); // 成功才刷新本地快取 return true; } return false; // 失敗 → 本地快取保持「貼圖仍 active」 } 更糟的是，後端的 afterUpdate lifecycle 在使用者更新 avatar 時，會自動停用貼圖。所以資料庫實際上是正確的——但前端手上那份 stickerResponse（上次呼叫 /api/stickers/available 取回、存在記憶體裡的快照）停留在「貼圖還 active」的舊狀態，沒有被重新抓。UI 優先顯示 active 貼圖，於是覆蓋了剛存進去的照片。\n這就是所謂的快取未同步：資料庫是真相來源，但前端讀的是自己那份本地副本；副本沒更新到，UI 就會看到與後端不一致的畫面。\n真正的 bug 是「失敗時缺少 graceful degradation」——既然後端 lifecycle 會兜底，前端失敗時至少也該 fetchStickers() 同步一次。\n整個 cascade 畫出來就清楚了：\n關鍵洞察：資料庫其實是對的，壞掉的只有那條「前端因為 API 失敗而沒刷新的本地快取」。真相永遠在 log 跟資料流裡，不在 UI 顯示的結果。\n衍生 Bug：null 欄位觸發 Strapi ValidationError 修完權限後測試子帳號，又冒出 400：\nValidationError: realName must be a `string` type, but the final value was: `null` 前端把空字串轉成 null 送出，Strapi 不收 null。順手一起修——改成只把非 null 欄位放進 PUT body：\n// Before final data = {\u0026#39;realName\u0026#39;: memberName, \u0026#39;nickname\u0026#39;: nickname, ...}; // After final data = \u0026lt;String, dynamic\u0026gt;{}; if (memberName != null) data[\u0026#39;realName\u0026#39;] = memberName; if (nickname != null) data[\u0026#39;nickname\u0026#39;] = nickname; // ... 其他欄位同樣處理 這個 pattern 原本就用在 avatar 欄位，只是沒擴及其他欄位。典型的「部分正確」陷阱。\n延伸：這不只是 Strapi 的問題 真正值得記住的是：權限配置存在 admin panel / metadata / rules file，而不是跟著 git 走，這個 anti-pattern 幾乎出現在每個 admin-panel 型後端。\n容易中招的後端：\n後端 權限位置 漂移場景 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 是經典 相對防漏的做法：\nCode-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 方便非工程師操作、代價是配置脫離版控，端點多了以後漂移幾乎不可避免。\n結論：三個預防原則 把權限配置進 git：用 config sync / metadata export / IaC，只要 PR 能 diff，漂移就會被攔下 寫端對端權限測試：每支 endpoint 都該有「未授權使用者該被拒絕」的測試；CI 跑一遍，漂移會在 build 階段就噴 失敗時降級而非沉默：前端對關鍵 API 失敗應該有補償機制，不要預設「失敗 = 什麼都不做」，讓後端的 lifecycle 等補救機制有機會接手 核心心法：錯誤訊息會說謊，權限配置會漂移，前端快取不會自己同步。這三件事放在一起，就是那個「看起來明明存成功了卻顯示不對」的 bug。\n","permalink":"https://peter-notes.com/posts/admin-panel-permission-drift-cms-backends/","summary":"\u003ch2 id=\"症狀選完手機照片畫面卻還是貼圖\"\u003e症狀：選完手機照片，畫面卻還是貼圖\u003c/h2\u003e\n\u003cp\u003e使用者回報的步驟很簡單：點頭像 → 選「從手機圖庫」→ 選張照片 → 儲存。跳回會員中心，頭像\u003cstrong\u003e還是原本的系統貼圖\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e再點進去編輯頁，看到的又是手機照片。明明存成功了，為什麼 UI 顯示不一致？\u003c/p\u003e\n\u003cp\u003e這個 bug 表面上是前端畫面問題，但用 log 逼出來的真相，藏在三層之外——包括一個會在幾乎所有 admin-panel 型後端都出現的經典陷阱。\u003c/p\u003e\n\u003ch2 id=\"偵錯跟著-log-走別信錯誤訊息\"\u003e偵錯：跟著 log 走，別信錯誤訊息\u003c/h2\u003e\n\u003cp\u003e第一步是打開前端 log 重現流程。關鍵幾行：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eStickerService -\u0026gt; Deactivating all stickers\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eStickerService -\u0026gt; Error: status code of 403\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eStickerService -\u0026gt; Error Response: {status: 403, message: Forbidden}\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eStickerService -\u0026gt; Deactivate all failed: You do not own this sticker\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e最後這句「You do not own this sticker」幾乎讓我停在錯誤方向——去查使用者到底擁有哪幾個貼圖。但翻前端程式碼才發現：那不是後端回的訊息，是前端 \u003ccode\u003e_handleDioError\u003c/code\u003e 把「403 Forbidden」硬翻譯成這句。\u003c/p\u003e\n\u003cp\u003e**這是第一個教訓：HTTP 狀態碼與文字訊息之間的語意對應是會說謊的。**真正的錯誤只有一個字——\u003ccode\u003eForbidden\u003c/code\u003e。跟 ownership 無關，純粹是權限問題。\u003c/p\u003e\n\u003ch2 id=\"第一層根因admin-panel-的權限設定漂移\"\u003e第一層根因：Admin Panel 的權限設定漂移\u003c/h2\u003e\n\u003cp\u003e翻 Strapi admin → Settings → Users \u0026amp; Permissions → Roles → \u003cstrong\u003eAuthenticated\u003c/strong\u003e → Sticker section，發現 \u003ccode\u003edeactivateAll\u003c/code\u003e 這支 route 的權限\u003cstrong\u003e沒勾\u003c/strong\u003e。\u003c/p\u003e","title":"頭貼切不回去？一個 Bug 揪出 Admin Panel 權限漂移的跨後端通病"},{"content":"一個「切換帳號」引爆四個 Bug 子母帳號功能很直覺：母帳號可以切換到子帳號視角，查看子帳號的健康日記、操作資料。技術上就是換一組 JWT，重新載入資料。\n聽起來簡單，實際上踩了四個 GetX 狀態管理的地雷，每個都不是表面看得出來的問題。\nBug 連鎖反應全景圖 先看整體：四個 bug 環環相扣，每修一個就暴露下一個。\n第一坑：JWT 換了，身份沒換 切換帳號的核心邏輯只做了一件事——替換 JWT：\n// 切換到子帳號 await tokenService.saveToken(childJwt); tokenService.token = childJwt; await tokenService.saveRefreshToken(childRefreshToken); 看起來沒問題？但 App 裡所有 API 呼叫不只靠 JWT，還依賴 userId 和 documentId。這兩個值存在 SharedPreferences 裡，切換帳號時完全沒有更新。\n結果：子帳號的 JWT 搭配母帳號的 userId 去呼叫 API → 後端權限不符 → 500 錯誤。日記的 GraphQL 查詢用母帳號的 documentId 過濾 → 查到的是母帳號的資料，不是子帳號的。\n為什麼容易忽略？ 因為 JWT 本身就攜帶使用者身份，直覺上換了 JWT 就等於換了身份。但 App 端的 REST API 和 GraphQL 查詢是用 SharedPreferences 裡的 userId / documentId 組裝 URL 和 filter，這兩套身份來源沒有同步。\n第二坑：ViewModel 刪了重建，UI 卻指向屍體 帳號切換後，透過 clearUserControllers() 刪除所有 ViewModel，讓 fenix: true 機制自動重建新的實例：\n// 刪除舊的 ViewModel Get.delete\u0026lt;CompleteDiaryViewModel\u0026gt;(force: true); Get.delete\u0026lt;HomeScreenFrontPageViewModel\u0026gt;(force: true); // 導航到首頁，觸發重建 await Get.offAllNamed(RoutePath.homePage); 理論上 fenix: true 會在下次 Get.find() 時建立全新的實例。但 DashBoardScreen 是 StatelessWidget，裡面用 IndexedStack 管理五個頁面。問題在於：StatelessWidget 的 final 欄位在建構時就透過 Get.find() 綁定了 ViewModel 參考。\n如果 Get.offAllNamed 沒有完整重建整個 widget tree，舊的 widget 仍持有已被 Get.delete 刪除的 ViewModel 參考。結果：使用者輸入資料 → API 呼叫走的是新 ViewModel → 成功寫入後端。但 UI 的 Obx 監聽的是舊 ViewModel → 畫面永遠不更新。\n更隱蔽的是 clearUserControllers() 裡的雙重清除：\n// 手動呼叫 onClose 後又 Get.delete（會再呼叫一次 onClose） completeDiaryVM.onClose(); // TabController 被 dispose Get.delete\u0026lt;CompleteDiaryViewModel\u0026gt;(force: true); // 再次 dispose → 拋錯 第二次 onClose() 嘗試 dispose 已經 dispose 過的 TabController → 拋出異常 → 被 try-catch 吞掉 → Get.delete 沒有執行 → 舊 ViewModel 殘留，_isInitialized 仍為 true → onInit() 不再執行 → Tab 永遠不初始化 → 無限轉圈圈。\n修正思路：不刪除 ViewModel，改為原地重新載入資料。 這樣所有 widget 持有的參考永遠有效：\n// 不再 delete + recreate，直接重新載入 final frontPageVM = Get.find\u0026lt;HomeScreenFrontPageViewModel\u0026gt;(); frontPageVM.fetchUserData(); await frontPageVM.healthDiaryList(); final diaryVM = Get.find\u0026lt;CompleteDiaryViewModel\u0026gt;(); diaryVM.updateTabs(memberLevel: memberLevel, forceUpdate: true); 第三坑：全域變數凍結在第一次 import 日記頁面的對話框函式放在獨立檔案，頂層有一個全域變數：\n// health_diary_dialogs.dart final homescreenfrontpageViewmodel = Get.find\u0026lt;HomeScreenFrontPageViewModel\u0026gt;(); Dart 的 top-level final 變數只在第一次存取時求值，之後永遠不變。帳號切換後 ViewModel 被重建，但這個全域變數仍指向舊的實例。所有透過 dialog 操作的功能——體重、體脂、狀態選擇——全部靜默失敗。\n修正很簡單，改成 getter：\n// 每次呼叫都取得最新的 ViewModel HomeScreenFrontPageViewModel get _viewModel =\u0026gt; Get.find\u0026lt;HomeScreenFrontPageViewModel\u0026gt;(); 為什麼值得提？ 因為 Get.find() 放在 StatelessWidget 的 final 欄位裡是安全的（每次 widget 建構都會重新求值），但放在 top-level 就是定時炸彈。語法一樣，行為完全不同。\n第四坑：記憶體狀態 vs 持久化狀態 母帳號切換到子帳號後，母帳號的 JWT、userId 等資訊存在 AccountSwitchService 的實例變數裡。使用者滑掉 App 再打開 → Service 重建 → 所有實例變數歸零 → 母帳號控制權消失，App 直接用子帳號身份登入。\n這是「記憶體狀態」和「持久化狀態」的經典問題。解法是切換時將母帳號資訊寫入 SharedPreferences，App 啟動時檢測並自動切回：\n// App 啟動時（bootstrap.dart） final accountSwitchService = Get.find\u0026lt;AccountSwitchService\u0026gt;(); await accountSwitchService.restoreAndSwitchBackIfNeeded(); 設計決策： 為什麼不維持子帳號狀態，而是自動切回母帳號？因為子帳號模式是「母帳號在監控」的概念，App 被殺掉代表監控中斷，重啟後應該回到母帳號的安全狀態。\n四個坑的共同根因 回頭看，這四個問題都指向同一個根因：GetX 的便利性掩蓋了狀態管理的複雜度。\n問題 表象 根因 500 錯誤 API 呼叫失敗 多重身份來源沒同步 UI 不更新 資料存了但看不到 widget 持有已死的 ViewModel Dialog 失效 按鈕沒反應 全域變數凍結在舊參考 母帳號消失 App 重開變子帳號 關鍵狀態只存在記憶體 Get.find() 讓依賴注入變得極其簡單，但也讓開發者忽略了一個事實：參考（reference）和實例（instance）是兩回事。當你 delete 再 recreate 一個 ViewModel，所有之前拿到的參考都指向一具屍體。\n最終的解法不是修補 GetX 的 lifecycle，而是改變策略：不要刪除 ViewModel，讓它活著，只重新載入資料。這樣所有參考永遠有效，Obx 永遠能收到通知。\n有時候最好的修復不是修理壞掉的機制，而是繞過它。\n","permalink":"https://peter-notes.com/posts/flutter-getx-account-switch-state-trap/","summary":"\u003ch2 id=\"一個切換帳號引爆四個-bug\"\u003e一個「切換帳號」引爆四個 Bug\u003c/h2\u003e\n\u003cp\u003e子母帳號功能很直覺：母帳號可以切換到子帳號視角，查看子帳號的健康日記、操作資料。技術上就是換一組 JWT，重新載入資料。\u003c/p\u003e\n\u003cp\u003e聽起來簡單，實際上踩了四個 GetX 狀態管理的地雷，每個都不是表面看得出來的問題。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"bug-連鎖反應全景圖\"\u003eBug 連鎖反應全景圖\u003c/h2\u003e\n\u003cp\u003e先看整體：四個 bug 環環相扣，每修一個就暴露下一個。\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/flutter-getx-account-switch-state-trap-diagram-7c9730f2.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003chr\u003e\n\u003ch2 id=\"第一坑jwt-換了身份沒換\"\u003e第一坑：JWT 換了，身份沒換\u003c/h2\u003e\n\u003cp\u003e切換帳號的核心邏輯只做了一件事——替換 JWT：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-dart\" data-lang=\"dart\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// 切換到子帳號\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"kd\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003etokenService\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esaveToken\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003echildJwt\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003etokenService\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etoken\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003echildJwt\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003etokenService\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esaveRefreshToken\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003echildRefreshToken\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e看起來沒問題？但 App 裡所有 API 呼叫不只靠 JWT，還依賴 \u003ccode\u003euserId\u003c/code\u003e 和 \u003ccode\u003edocumentId\u003c/code\u003e。這兩個值存在 SharedPreferences 裡，\u003cstrong\u003e切換帳號時完全沒有更新\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e結果：子帳號的 JWT 搭配母帳號的 userId 去呼叫 API → 後端權限不符 → \u003cstrong\u003e500 錯誤\u003c/strong\u003e。日記的 GraphQL 查詢用母帳號的 documentId 過濾 → 查到的是母帳號的資料，不是子帳號的。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e為什麼容易忽略？\u003c/strong\u003e 因為 JWT 本身就攜帶使用者身份，直覺上換了 JWT 就等於換了身份。但 App 端的 REST API 和 GraphQL 查詢是用 SharedPreferences 裡的 \u003ccode\u003euserId\u003c/code\u003e / \u003ccode\u003edocumentId\u003c/code\u003e 組裝 URL 和 filter，這兩套身份來源沒有同步。\u003c/p\u003e","title":"Flutter 子母帳號切換踩了四個坑：GetX 狀態管理的隱藏地雷"},{"content":"Build 突然壞了，但沒有人改過 CI 設定 iOS 的 Jenkins pipeline 突然開始失敗。上一版（058）還好好的，下一版（059）就掛了。再跑一次（060），還是掛。\n第一時間檢查 git diff —— 兩版之間只有 Dart 程式碼改動，沒有任何人碰過 Fastfile、Jenkinsfile、或 CI 相關設定。程式碼改動是 Flutter 層的 bug fix，跟 iOS build 流程完全無關。\n這是最令人困惑的情境：什麼都沒改，但 CI 壞了。\n先看一下各版的 build 結果：\nTag 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 的前期設定階段就失敗了。\n先理解 Fastlane 的 Keychain 機制 在深入除錯之前，先理解 iOS code signing 在 CI 環境的運作方式。\niOS App 要上架必須用 Apple Distribution Certificate 簽名。這個憑證（.p12 私鑰 + .cer 公鑰）存在 macOS 的 Keychain 裡。你在 Xcode 裡簽名時用的就是 login keychain 裡的憑證。\n但在 CI 環境，情況更複雜：\n為什麼不直接用 login keychain？ 因為 CI 環境需要隔離。多個 build 可能同時執行，login keychain 可能被鎖定，而且 CI 結束後不應該留下私鑰。setup_ci 建立的暫時 keychain 解決了這些問題 —— 用完就刪，互不干擾。\nFastlane match 則解決了憑證分發的問題。它把 Apple Certificate 和 Provisioning Profile 加密存在 Git repo 裡，CI 執行時下載、解密、匯入暫時 keychain。整個團隊共用同一份憑證，不需要每台機器手動安裝。\n理解這個架構後，回到我們的錯誤。\n第一層：Keychain 建立失敗 Fastlane 的 build summary 只跑了兩步就掛了：\n+------+----------------------------+------+ | Step | Action | Time | +------+----------------------------+------+ | 1 | Verifying fastlane version | 0s | | 2 | default_platform | 0s | | 💥 | setup_ci | 0s | +------+----------------------------+------+ 展開錯誤訊息：\nINFO: Creating temporary keychain: \u0026#34;fastlane_tmp_keychain\u0026#34; INFO: Temporary keychain fastlane_tmp_keychain not found, skipping delete ERROR: Shell command exited with exit status 1 instead of 0. setup_ci 在建立暫時 keychain 時失敗了。它的流程是「先刪舊的、再建新的」，但第一步 delete 就跳過了（not found），第二步 create 卻失敗了。\nSSH 到 Jenkins 機器查 keychain 狀態：\n$ security list-keychains \u0026#34;/Users/ci/Library/Keychains/fastlane_keychain-db\u0026#34; \u0026#34;/Users/ci/Library/Keychains/login.keychain-db\u0026#34; \u0026#34;/Library/Keychains/System.keychain\u0026#34; 注意第一行 —— 多了一個 fastlane_keychain-db。這不是 setup_ci 建立的 fastlane_tmp_keychain，而是一個名字不同的殘留 keychain。它佔據了 keychain 搜尋列表的位置，影響了新 keychain 的建立。\n清掉它：\n$ security delete-keychain fastlane_keychain-db $ security list-keychains \u0026#34;/Users/ci/Library/Keychains/login.keychain-db\u0026#34; \u0026#34;/Library/Keychains/System.keychain\u0026#34; 乾淨了。重跑 build。\n第二層：清掉殘留後，換一個錯誤 重跑 build，setup_ci 通過了，match 也成功下載並匯入憑證。但 pipeline 在下一步掛掉：\n+------+----------------------------+------+ | Step | Action | Time | +------+----------------------------+------+ | 1 | Verifying fastlane version | 0s | | 2 | default_platform | 0s | | 3 | setup_ci | 0s | | 4 | xcode_select | 0s | | 5 | is_ci | 0s | | 6 | match | 3s | | 💥 | unlock_keychain | 0s | | 8 | delete_keychain | 0s | +------+----------------------------+------+ 這次多跑了幾步，但掛在 unlock_keychain。錯誤訊息：\nERROR: Could not locate the provided keychain. Tried: /Users/ci/Library/Keychains/fastlane_keychain-db 嘗試 unlock 的是 fastlane_keychain —— 就是我們剛剛刪掉的那個。\n翻開 Fastfile 第 209 行：\n# 解鎖 fastlane_keychain 讓 Flutter build 能存取簽名憑證 unlock_keychain( path: \u0026#34;fastlane_keychain\u0026#34;, password: \u0026#34;temp_password\u0026#34;, set_default: false ) 這段程式碼依賴一個不由 CI 流程建立的 keychain。 它期望 fastlane_keychain 存在於 Jenkins 機器上，但現在 CI 流程用的是 setup_ci 建立的 fastlane_tmp_keychain。\n問題變得更有趣了：這行程式碼一直都在 Fastfile 裡，為什麼之前不會失敗？\n考古：這段程式碼的來歷 翻看 Fastfile 的結構，答案很清楚。這個專案的 iOS 簽名經歷過一次遷移：\n舊架構（手動管理）：\n# 自己建立 keychain create_keychain( name: \u0026#34;fastlane_keychain\u0026#34;, password: \u0026#34;temp_password\u0026#34;, unlock: true, timeout: 3600 ) # 手動匯入 .p12 憑證 import_certificate( certificate_path: \u0026#34;cert.p12\u0026#34;, keychain_name: \u0026#34;fastlane_keychain\u0026#34;, keychain_password: \u0026#34;temp_password\u0026#34; ) # 手動安裝 provisioning profile install_provisioning_profile( path: \u0026#34;../keys/AppStore.mobileprovision\u0026#34; ) 新架構（match 自動管理）：\n# setup_ci 建立 fastlane_tmp_keychain setup_ci(force: true) # match 自動下載憑證並匯入 fastlane_tmp_keychain match( type: \u0026#34;appstore\u0026#34;, app_identifier: \u0026#34;com.example.myapp\u0026#34;, readonly: is_ci ) 遷移的時候，create_keychain 和 import_certificate 被正確替換成了 setup_ci + match。舊的程式碼也被註解掉了。但 unlock_keychain 這段沒有被刪掉 —— 可能是遷移時覺得「多 unlock 一次沒壞處」，就留著了。\n元件 舊架構 新架構 遷移時處理 Keychain 建立 create_keychain(\u0026quot;fastlane_keychain\u0026quot;) setup_ci → fastlane_tmp_keychain ✅ 已替換 憑證匯入 import_certificate match ✅ 已替換 Profile 安裝 install_provisioning_profile match ✅ 已註解 Keychain 解鎖 unlock_keychain(\u0026quot;fastlane_keychain\u0026quot;) 不需要（setup_ci 已處理） ❌ 遺漏 幽靈依賴：為什麼「一直都能過」 知道了歷史，關鍵問題是：這段有問題的程式碼怎麼能存活這麼久？\n答案是 Jenkins 機器上殘留了一個 fastlane_keychain-db 檔案。它不是由新的 CI 流程建立的，而是舊架構遺留下來的。遷移到 match 後，舊的 create_keychain 被移除了，但它之前建立的 keychain 檔案一直安靜地躺在機器上。\n這就是幽靈依賴（Ghost Dependency）—— 程式碼依賴一個不由自身流程管理的外部狀態。它的危險在於：\n表面上一切正常，因為依賴剛好被滿足 無法從程式碼中看出問題，因為邏輯本身沒有 bug 觸發時機不可預測，任何改變環境狀態的操作都可能引爆 修正：刪掉三行 修正異常簡單 —— 刪掉三個 build lane 裡的 unlock_keychain 呼叫。setup_ci 已經建立並 unlock fastlane_tmp_keychain，match 把憑證裝進同一個 keychain，整個流程自給自足：\nsetup_ci(force: true) # 建立 + unlock fastlane_tmp_keychain match(type: \u0026#34;appstore\u0026#34;, readonly: is_ci) # 下載憑證 → 匯入 fastlane_tmp_keychain # 不需要額外的 unlock_keychain 推上去，Jenkins 跑了 12 分鐘，SUCCESS。\n遠端診斷的技巧 這次除錯有一個實務上的挑戰：Jenkins 跑在辦公室的 Mac mini 上，我不在現場。所有操作都透過 SSH 遠端完成。幾個實用的指令記錄一下：\n查看 keychain 狀態：\n# 列出搜尋列表中的 keychains $ security list-keychains # 列出 keychain 目錄下的檔案 $ ls -la ~/Library/Keychains/ # 查看 keychain 裡的簽名身份 $ security find-identity -v -p codesigning 透過 Jenkins API 查 build 狀態：\n# 查看特定 job 的最新 build 結果 $ curl -s -u \u0026#34;user:token\u0026#34; \\ \u0026#34;http://localhost:8080/job/mobile-ci/job/prod-tag/1/api/json?tree=building,result\u0026#34; # 觸發重新 build（需要 CSRF crumb） $ CRUMB=$(curl -s -u \u0026#34;user:token\u0026#34; \\ \u0026#34;http://localhost:8080/crumbIssuer/api/json\u0026#34; | python3 -c \\ \u0026#34;import sys,json; print(json.load(sys.stdin)[\u0026#39;crumb\u0026#39;])\u0026#34;) $ curl -s -b cookies -X POST -u \u0026#34;user:token\u0026#34; \\ -H \u0026#34;Jenkins-Crumb: $CRUMB\u0026#34; \\ \u0026#34;http://localhost:8080/job/mobile-ci/job/prod-tag/build\u0026#34; \\ --data-urlencode json=\u0026#34;{}\u0026#34; 清理殘留 keychain：\n# 正確清理（同時從搜尋列表和檔案系統移除） $ security delete-keychain fastlane_keychain-db # 如果 delete 失敗，手動清檔案 $ rm -f ~/Library/Keychains/fastlane_keychain-db 問題本身不難，難的是它藏得深 老實說，這次用 AI 輔助除錯，從 log 到定位原因不到十分鐘。真正花時間的不是找原因，而是處理連鎖反應 —— 清掉殘留 keychain 後冒出第二個錯誤，修完 Fastfile 還要等 build 跑完驗證。\n但如果沒有 AI 幫忙遠端讀 Jenkins log、SSH 查 keychain 狀態、交叉比對成功和失敗 build 的差異，這件事至少要多花一個小時手動翻 log。\n真正值得記住的不是除錯技巧，而是這類 bug 的隱藏模式：\n時間延遲。 遷移到 match 是幾個月前的事，unlock_keychain 一直靜靜地依賴著殘留的 keychain。問題的根因（遷移時遺漏刪除）和症狀（build 失敗）之間隔了好幾個月。\n誤導性的成功。 如果遷移後第一次 build 就失敗，問題會當場修正，30 秒結束。但因為殘留 keychain「碰巧存在」，build「碰巧能過」，這段多餘的程式碼就像未爆彈，安靜地等了好幾個月。\n環境耦合。 換一台全新的 CI 機器，反而會立刻失敗（因為根本沒有殘留 keychain），問題會當場被發現。正是因為舊機器上殘留了 keychain，bug 才能隱藏這麼久。\n預防：CI 遷移的 Checklist 遷移 CI 流程（不只是 Fastlane）時，用這個 checklist 避免幽靈依賴：\n遷移時：\n搜尋舊名稱：遷移後在整個 repo grep 舊的 keychain 名稱、環境變數、路徑。任何對舊機制的引用都應該被移除，而不是「留著以防萬一」。 在乾淨環境測試：遷移後至少跑一次全新環境的 build —— 刪掉所有快取、暫存檔、殘留 keychain。如果只在舊環境能過，就是幽靈依賴。 刪除而不是註解：舊的程式碼要刪掉。Fastfile 裡被註解掉的 create_keychain 不會執行，但沒被註解的 unlock_keychain 會繼續執行並依賴不存在的東西。 日常維護：\nCI cleanup 要強制執行：Fastlane 的 ensure block 一定要有 cleanup。如果 cleanup 不可靠（如 process 被 kill），在每次 build 開始前先主動清理暫時 keychain。 定期在新機器跑 build：如果你的 CI 只跑在一台固定機器上，定期在另一台乾淨機器上測試一次。幽靈依賴在乾淨環境會立刻暴露。 CI 環境視為 immutable：理想的 CI 環境每次 build 都從乾淨狀態開始（如 Docker container 或 ephemeral VM）。如果做不到，至少確保 pipeline 不依賴「上次 build 留下的東西」。 最終，這個問題的核心教訓很簡單：遷移不是「新的能跑」就結束了，「舊的要刪乾淨」才算完成。\n","permalink":"https://peter-notes.com/posts/fastlane-keychain-ghost-dependency-ci-failure/","summary":"\u003ch2 id=\"build-突然壞了但沒有人改過-ci-設定\"\u003eBuild 突然壞了，但沒有人改過 CI 設定\u003c/h2\u003e\n\u003cp\u003eiOS 的 Jenkins pipeline 突然開始失敗。上一版（058）還好好的，下一版（059）就掛了。再跑一次（060），還是掛。\u003c/p\u003e\n\u003cp\u003e第一時間檢查 git diff —— 兩版之間只有 Dart 程式碼改動，沒有任何人碰過 \u003ccode\u003eFastfile\u003c/code\u003e、\u003ccode\u003eJenkinsfile\u003c/code\u003e、或 CI 相關設定。程式碼改動是 Flutter 層的 bug fix，跟 iOS build 流程完全無關。\u003c/p\u003e\n\u003cp\u003e這是最令人困惑的情境：\u003cstrong\u003e什麼都沒改，但 CI 壞了。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e先看一下各版的 build 結果：\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eTag\u003c/th\u003e\n          \u003cth\u003eBuild\u003c/th\u003e\n          \u003cth\u003e結果\u003c/th\u003e\n          \u003cth\u003e耗時\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eprod-0.1.04+2026000058\u003c/td\u003e\n          \u003ctd\u003e#1\u003c/td\u003e\n          \u003ctd\u003eSUCCESS\u003c/td\u003e\n          \u003ctd\u003e9.5 分鐘\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eprod-0.1.04+2026000059\u003c/td\u003e\n          \u003ctd\u003e#1\u003c/td\u003e\n          \u003ctd\u003eFAILURE\u003c/td\u003e\n          \u003ctd\u003e2.3 分鐘\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eprod-0.1.04+2026000060\u003c/td\u003e\n          \u003ctd\u003e#1\u003c/td\u003e\n          \u003ctd\u003eFAILURE\u003c/td\u003e\n          \u003ctd\u003e2.3 分鐘\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e059 和 060 都只跑了 2 分多鐘就掛了 —— 連 Flutter build 都沒跑到，在 Fastlane 的前期設定階段就失敗了。\u003c/p\u003e\n\u003ch2 id=\"先理解-fastlane-的-keychain-機制\"\u003e先理解 Fastlane 的 Keychain 機制\u003c/h2\u003e\n\u003cp\u003e在深入除錯之前，先理解 iOS code signing 在 CI 環境的運作方式。\u003c/p\u003e","title":"Fastlane CI 突然壞了：一個被遺忘的 Keychain 依賴如何讓 Build 連續失敗"},{"content":"廠商一句話，整個回調機制要重寫 我們的健康管理 App 整合了一台第三方氣酮檢測裝置。綁定流程很單純：使用者從我們的 App 跳到廠商的 App 完成綁定，綁定完成後跳回來，顯示成功或失敗。\n原本的回調方式是 URL Scheme：\nmyapp://device-bindback?status=success iOS 上一直運作正常，直到廠商的 Android App 更新後回報：他們不支援 URL Scheme 回調，要求改用 Universal Link。\n這不是改一行 URL 的事。Universal Link 牽涉到網域驗證、靜態檔案部署、平台設定、安全機制，幾乎是把整個回調架構重新設計。\n在深入之前，先釐清幾個關鍵術語：\n術語 說明 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 不同。 改動前後的流程差異，用一張圖看最清楚：\nURL Scheme 和 Universal Link 的本質差異 URL Scheme 像是在門上掛一個自訂名牌 —— 任何人都可以掛相同的名牌，系統無法驗證誰才是真正的主人。\nUniversal Link 則是把你的 App 和你的網域綁定在一起。iOS 會去 https://your-domain.com/.well-known/apple-app-site-association 抓一份 JSON 設定檔，確認「這個網域的特定路徑，確實屬於這個 App」。Android 的 App Links 也是同樣原理，透過 assetlinks.json 驗證。\n這代表三件事：\n你需要一個自己控制的網域，而且上面要放驗證檔案 驗證檔案必須在 App 安裝前就部署好，因為 iOS 在安裝時下載 AASA 回調 URL 從 myapp:// 變成 https://，本質上變成了一個真實的網頁 URL 遷移的三個戰場 戰場一：網站端 — 放驗證檔案和 fallback 頁面 AASA 檔案本身不複雜，但有幾個容易踩的坑：\n檔案不能有副檔名。 apple-app-site-association 就是檔名，不是 apple-app-site-association.json。如果你的 web server 沒設定好，可能會回傳錯誤的 Content-Type。\n# Nginx 必須在 SPA fallback 之前攔截 location /.well-known { root /usr/share/nginx/html; default_type application/json; } SPA 的 try_files 會吃掉所有路徑。 如果你的前端是 Vue 或 React SPA，try_files $uri $uri/ /index.html 會把 .well-known 路徑導到 index.html，Apple 驗證就會失敗。必須把 .well-known 的 location block 放在 location / 之前。\n另一個容易忽略的是 fallback 頁面。Universal Link 不保證 100% 觸發 App 攔截 —— Safari 有時候會直接開網頁。所以你需要一個 fallback 頁面，在 App 沒攔截到的時候嘗試用 URL Scheme 喚醒：\n// iOS: 嘗試 URL Scheme，失敗後導向 App Store window.location = `myapp://device-bindback?status=${status}` setTimeout(() =\u0026gt; { window.location = \u0026#39;https://apps.apple.com/app/id123456789\u0026#39; }, 2000) Android 也用同樣策略。原本想用 intent:// URI（Chrome 原生支援），但 LINE 和 Facebook 的內建瀏覽器不吃 intent://，最終統一用 URL Scheme + setTimeout。\n戰場二：App 端 — 平台設定和 handler 改寫 iOS 要在 entitlements 加 Associated Domains：\n\u0026lt;key\u0026gt;com.apple.developer.associated-domains\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;applinks:www.your-domain.com\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; Android 要在 AndroidManifest 加 intent-filter，重點是 autoVerify=\u0026quot;true\u0026quot;：\n\u0026lt;intent-filter android:autoVerify=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;action android:name=\u0026#34;android.intent.action.VIEW\u0026#34; /\u0026gt; \u0026lt;category android:name=\u0026#34;android.intent.category.DEFAULT\u0026#34; /\u0026gt; \u0026lt;category android:name=\u0026#34;android.intent.category.BROWSABLE\u0026#34; /\u0026gt; \u0026lt;data android:scheme=\u0026#34;https\u0026#34; android:host=\u0026#34;www.your-domain.com\u0026#34; android:path=\u0026#34;/app/device-bindback\u0026#34; /\u0026gt; \u0026lt;/intent-filter\u0026gt; Deep link handler 要同時處理新舊兩種格式 —— Universal Link（https:// scheme）和舊的 URL Scheme（myapp://），因為 fallback 頁面會用 URL Scheme 嘗試喚醒，而且舊版 App 使用者不應該受影響。\n戰場三：安全性 — 不能信任 callback 的 status 原本的做法有一個嚴重問題：任何人都可以偽造 callback URL。 只要打開 https://www.your-domain.com/app/device-bindback?status=success，App 就認定綁定成功。\n解法是加一個一次性 token。整個驗證流程如下：\n具體步驟：\nApp 發起綁定時，產生一個隨機 token 存在本地（SharedPreferences） Token 跟著 callback URL 送給廠商 廠商完成綁定後，原封不動帶回 token App 比對 token 一致才認定結果有效 // 產生 token 並持久化（不能只存記憶體！） final token = List.generate( 32, (_) =\u0026gt; Random.secure().nextInt(16).toRadixString(16), ).join(); await prefs.setString(\u0026#39;pending_token\u0026#39;, token); await prefs.setInt(\u0026#39;token_timestamp\u0026#39;, DateTime.now().millisecondsSinceEpoch); 為什麼不能只存記憶體？ 因為使用者跳到廠商 App 的過程中，iOS 很可能在背景 kill 你的 App。回來的時候記憶體裡的 token 已經消失，合法的 callback 反而被當成非法。\nToken 還要加上過期時間（我們設 5 分鐘），防止舊 token 被重用。\n部署順序：先網站，後 App 這是最容易搞錯的地方：\nApple 在 App 安裝時下載 AASA，不是每次啟動。所以：\n先部署網站 — AASA 和 assetlinks.json 上線 驗證 Apple CDN 已快取 — curl https://app-site-association.cdn-apple.com/a/v1/www.your-domain.com 再發佈 App — 安裝時 iOS 才會抓到正確的 AASA 順序反了的話，使用者安裝 App 時 AASA 還不存在，Universal Link 就不會生效，而且不會自動重試 —— 要等使用者重新安裝 App 才會再次驗證。\n另一個要注意的命名衝突 我們的 callback URL 原本用 token 作為 query parameter 名稱。部署到 staging 後發現 Vue Router 的 navigation guard 會攔截 URL 中的 token 參數，把它當成 JWT 認證 token 處理，然後從 URL 中移除。\nFallback 頁面收到的 token 值永遠是空的。\n解法很簡單：把參數改名為 callback_token。但如果沒有部署 staging 先測試，這個問題會直接在 production 爆掉。\n上線後的兩個坑：Android 跳 Play Store、iOS 按鈕不變綠 Universal Link 部署上線後，兩個平台各出了一個問題。都不是程式碼邏輯的錯，卻都花了不少時間定位。\nAndroid：assetlinks.json 的指紋陷阱 上線後 Android 使用者回報：從廠商 App 跳回來，不是開啟我們的 App，而是跳到 Play Store。\n要理解這個問題，先看 Android App Links 的驗證機制。前面提過 assetlinks.json 是 Android 版的 AASA —— 放在你網域的 /.well-known/ 路徑下，告訴 Android「這個網域的連結屬於這個 App」。但 Android 怎麼確認「這個 App」就是它宣稱的那個？答案是簽名指紋。\n每個 Android App 在打包時都會用一把私鑰簽名，產生一組獨一無二的 SHA-256 指紋（長這樣：AA:BB:CC:DD:... 共 32 組 hex）。assetlinks.json 裡放的就是這組指紋，Android 安裝 App 時會比對：「App 的簽名指紋」和「assetlinks.json 裡宣告的指紋」一致，才認定這個 App 有權攔截該網域的連結。\n整個驗證流程可以用這張圖理解：\n問題就出在「指紋比對」這一步。\n第一反應是 assetlinks.json 沒部署好。用 curl 確認檔案存在、Content-Type 正確、Google 的 Digital Asset Links API 也驗證通過。問題不在這裡。\n關鍵線索是用 adb 查裝置上的實際驗證狀態：\n$ adb shell pm get-app-links com.example.myapp Signatures: [A0:A5:35:C3:...] Domain verification state: www.your-domain.com: 1024 # 驗證失敗 裝置上 App 的簽名指紋（A0:A5:...），跟 assetlinks.json 裡的（38:20:...）不一樣。\n原因是 Google Play App Signing。這是理解這個坑的核心：Android App 的簽名其實分成兩把 key。\nKey 誰持有 用途 指紋來源 Upload key 你（本地 keystore） 上傳 AAB/APK 到 Play Console keytool -list -v -keystore your.jks App signing key Google（Play Console 管理） 對上架版 App 做最終簽名 Play Console → 設定 → 應用程式簽署 從 2021 年 8 月起，新上架的 App 強制啟用 Play App Signing。Google 會用自己的 key 重新簽名你上傳的 AAB，所以從 Play Store 安裝的 App，簽名指紋是 app signing key 的，不是你本地 keystore（upload key）的。\n我們犯的錯就是 assetlinks.json 裡只放了 upload key 的指紋。本地開發、CI、模擬器全部用 upload key 簽名，測試一切正常。但真實使用者從 Play Store 裝的 App 是 app signing key 簽名的 —— 指紋不匹配，驗證失敗。\n修正方式：到 Play Console → 設定 → 應用程式簽署，找到 App signing key 的 SHA-256 指紋，加進 assetlinks.json：\n{ \u0026#34;sha256_cert_fingerprints\u0026#34;: [ \u0026#34;AA:BB:CC:...(upload key，保留給 debug/CI)\u0026#34;, \u0026#34;DD:EE:FF:...(Play Store app signing key，這才是關鍵)\u0026#34; ] } 修正後用 adb 驗證：\n# 手動核准（測試用，模擬指紋匹配的狀態） $ adb shell pm set-app-links --package com.example.myapp 2 all # 驗證狀態 $ adb shell pm get-app-links com.example.myapp Domain verification state: www.your-domain.com: approved # 通過 # 取消核准，確認失敗行為 $ adb shell pm set-app-links --package com.example.myapp 0 all # → 開啟 Universal Link → Chrome 打開（復現問題） 教訓： 如果你的 App 走 Google Play 發佈，assetlinks.json 至少要放兩組指紋 —— upload key 和 Play Store app signing key。只放 upload key 是最常見的 Android App Links 失敗原因，而且開發階段完全不會發現，因為你的測試裝置用的永遠是 upload key。\niOS：token 驗證的過度防禦 iOS 的 Universal Link 攔截正常，App 也確實被喚醒了，但綁定按鈕的狀態沒有從「綁定」變成「綁定成功」。\n追蹤資料流：deep link handler 收到 URL → 解析 status=success → 呼叫 verifyToken() → 按鈕應該變綠。\n問題出在 token 驗證。遷移到 Universal Link 時，我們加了 callback_token 安全機制：App 發起綁定時產生隨機 token，廠商原封不動帶回，App 比對後才接受結果。\n但廠商的回調 URL 沒有帶回 callback_token 參數。verifyToken(null) 直接回傳 false，狀態就永遠不會更新 —— 而且不會報錯，完全靜默失敗。\n修正方式：把「沒有 token」和「token 驗證失敗」分開處理。沒有 token 時降級為無驗證模式，而不是直接拒絕：\n// 修正前：token 為 null 直接拒絕，整個流程卡死 if (token == null) return false; // 修正後：沒有 token 時跳過驗證，接受狀態 if (callbackToken == null) { _applyBindResult(status); // 降級為無驗證模式 return; } // 有 token 才做完整驗證 verifyToken(callbackToken).then((valid) { ... }); 這是典型的防禦過度問題。安全驗證沒有做 graceful degradation —— 當第三方廠商不配合帶回 token 時，整個功能就卡死了，而且沒有任何錯誤提示。\n教訓： 跟第三方整合的安全機制，要設計 fallback 路徑。驗證材料齊全就嚴格驗證，缺失就降級處理並留下日誌，而不是靜默吞掉整個流程。寫安全驗證時多問一句：如果對方沒照規格來，這段程式碼會怎樣？\n回顧：一次改動背後的連鎖反應 看起來只是「把 myapp:// 改成 https://」，實際上牽動了：\n網站：靜態驗證檔、Nginx 設定、新路由、fallback 頁面 App：iOS entitlements、Android manifest、兩個 service 改寫 安全機制：token 產生、持久化、過期、一次性使用、防重複觸發 部署流程：嚴格的先後順序 廠商溝通：新的 callback URL 格式和 token 規範 簽名驗證：Play Store app signing key 和 upload key 的差異 而且上線後還會冒出新問題 —— Android 的指紋不匹配和 iOS 的 token 靜默失敗，都不是開發階段能發現的。前者要真正透過 Play Store 安裝才會觸發，後者要等廠商實際回調才能重現。\n最終的架構比 URL Scheme 穩固很多 —— 有網域驗證防劫持、有 token 防偽造、有 fallback 防漏接。但代價是複雜度高了一個量級，而且每一層都有自己獨立的失敗模式。\n如果你正在評估要不要從 URL Scheme 遷移到 Universal Link，先確認兩件事：你有沒有一個自己控制的、已經部署好 HTTPS 的網域？ 以及 你的 Android App 是否透過 Play Store 發佈？ 如果是，記得去 Play Console 拿 app signing key 的指紋，不要只用本地 keystore 的。\n","permalink":"https://peter-notes.com/posts/flutter-universal-link-migration-from-url-scheme/","summary":"\u003ch2 id=\"廠商一句話整個回調機制要重寫\"\u003e廠商一句話，整個回調機制要重寫\u003c/h2\u003e\n\u003cp\u003e我們的健康管理 App 整合了一台第三方氣酮檢測裝置。綁定流程很單純：使用者從我們的 App 跳到廠商的 App 完成綁定，綁定完成後跳回來，顯示成功或失敗。\u003c/p\u003e\n\u003cp\u003e原本的回調方式是 URL Scheme：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003emyapp://device-bindback?status=success\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eiOS 上一直運作正常，直到廠商的 Android App 更新後回報：\u003cstrong\u003e他們不支援 URL Scheme 回調，要求改用 Universal Link。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e這不是改一行 URL 的事。Universal Link 牽涉到網域驗證、靜態檔案部署、平台設定、安全機制，幾乎是把整個回調架構重新設計。\u003c/p\u003e\n\u003cp\u003e在深入之前，先釐清幾個關鍵術語：\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e術語\u003c/th\u003e\n          \u003cth\u003e說明\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eURL Scheme\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e自訂的 URL 前綴（如 \u003ccode\u003emyapp://\u003c/code\u003e），讓其他 App 可以透過這個 URL 開啟你的 App。缺點是任何 App 都可以註冊相同的 scheme，沒有驗證機制。\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eUniversal Link\u003c/strong\u003e（iOS）\u003c/td\u003e\n          \u003ctd\u003eApple 的機制，把 HTTPS URL 和特定 App 綁定。系統透過 AASA 檔案驗證網域和 App 的關聯，確保只有合法的 App 能攔截該 URL。\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eApp Links\u003c/strong\u003e（Android）\u003c/td\u003e\n          \u003ctd\u003eGoogle 對應 Universal Link 的機制，透過 \u003ccode\u003eassetlinks.json\u003c/code\u003e 驗證網域和 App 的關聯，原理相同。\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eAASA\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003eApple App Site Association，放在 \u003ccode\u003ehttps://你的網域/.well-known/apple-app-site-association\u003c/code\u003e 的 JSON 檔案，告訴 iOS「哪些路徑要交給哪個 App 開啟」。\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eassetlinks.json\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003eAndroid 版的 AASA，放在 \u003ccode\u003ehttps://你的網域/.well-known/assetlinks.json\u003c/code\u003e，功能相同。\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eProvisioning Profile\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003eApple 的簽名設定檔，記錄 App 被允許使用哪些功能（如 Push Notifications、Associated Domains）。新增功能時必須重新產生。\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eDeep Link\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e泛指所有能從外部開啟 App 並導到特定頁面的連結，URL Scheme 和 Universal Link 都是 Deep Link 的實作方式。\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eSHA-256 指紋\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003eApp 簽名憑證的雜湊值（32 組 hex，如 \u003ccode\u003eAA:BB:CC:...\u003c/code\u003e），用來唯一識別一個 App 的簽名身份。\u003ccode\u003eassetlinks.json\u003c/code\u003e 靠它比對「宣稱的 App」和「裝置上的 App」是否同一個。\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eUpload Key\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e開發者本地 keystore 的私鑰，用來簽名上傳到 Play Console 的 AAB/APK。本地開發、CI、模擬器都用這把。\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eApp Signing Key\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003eGoogle Play 持有的私鑰，用來對上架版 App 做最終簽名。使用者從 Play Store 安裝的 App 是這把 key 簽的，指紋跟 upload key 不同。\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e改動前後的流程差異，用一張圖看最清楚：\u003c/p\u003e","title":"廠商說不支援 URL Scheme：跨 App 綁定從自訂協議遷移到 Universal Link 的完整實錄"},{"content":"前言：Tags (173) 的紅燈海 打開 Jenkins 的 Multibranch Pipeline 頁面，映入眼簾的是一整排紅色叉叉——173 個 tag，幾乎全是失敗的歷史建置記錄。這些 tag 從 dev-0.0.74+143 一路排到 dev-0.0.74+181，光是同一個版本就堆了 39 個。\n三個 repo（Flutter、Strapi、Vue）加起來超過 1000 個 tag。這不只是視覺上的噪音，更是 Jenkins 每次 scan 都要花時間處理的負擔。\n核心觀念：Jenkins 上的 Tag 不是 Jenkins 的 第一個直覺可能是「到 Jenkins 上刪」，但這是錯的。\nJenkins Multibranch Pipeline 的運作方式是定期掃描 Git repository，把發現的 branch 和 tag 當作獨立的 pipeline 來建置。換句話說，Jenkins 頁面上看到的 tag 就是 Git remote 上的 tag——Jenkins 只是一面鏡子。\n為什麼不能從 Jenkins 刪？ 因為即使你在 Jenkins UI 手動移除某個 tag 的建置記錄，下一次 scan 時 Jenkins 發現 remote 上 tag 還在，又會重新建立。要斬草除根，必須從 Git remote 下手。\n清理策略：保留最新，刪除冗餘 以 Flutter repo 為例，dev-0.0.74 版本產生了 39 個 tag（從 +143 到 +181），代表這個版本被建置了 39 次。但真正有意義的只有最後一個成功的版本。\n清理策略很直覺：每個版本系列只保留最新的一個 tag，其餘全刪。\n第一步：產生刪除清單 關鍵是如何自動識別「同一版本系列」並找出最新的 tag。以下腳本會遍歷所有 dev tag，找出每個版本前綴（如 dev-0.0.74）下除了最新以外的所有 tag：\ngit tag -l \u0026#34;dev-*\u0026#34; | while read tag; do # 提取版本前綴（去掉 +buildNumber） prefix=$(echo \u0026#34;$tag\u0026#34; | sed \u0026#39;s/+[0-9]*$//\u0026#39;) # 找出同前綴的所有 tag，按 build number 排序 siblings=$(git tag -l \u0026#34;${prefix}+*\u0026#34; | sort -t\u0026#39;+\u0026#39; -k2 -n) count=$(echo \u0026#34;$siblings\u0026#34; | wc -l | tr -d \u0026#39; \u0026#39;) # 只有存在多個 tag 時，才標記非最新的為待刪除 if [ \u0026#34;$count\u0026#34; -gt 1 ]; then latest=$(echo \u0026#34;$siblings\u0026#34; | tail -1) if [ \u0026#34;$tag\u0026#34; != \u0026#34;$latest\u0026#34; ]; then echo \u0026#34;$tag\u0026#34; fi fi done | sort -u \u0026gt; /tmp/tags_to_delete.txt 為什麼先產生清單再刪？ 這是防禦性做法。刪除 remote tag 是不可逆的操作，先輸出清單讓自己確認，比直接 pipe 到 git push 安全得多。\n第二步：批次刪除遠端與本地 確認清單沒問題後，一次性刪除：\n# 刪除遠端 tag（關鍵步驟） cat /tmp/tags_to_delete.txt \\ | sed \u0026#39;s|^|:refs/tags/|\u0026#39; \\ | xargs git push origin # 刪除本地 tag cat /tmp/tags_to_delete.txt | xargs git tag -d sed 's|^|:refs/tags/|' 這行把每個 tag 名稱轉換成 :refs/tags/tag-name 的格式——這是 git push 的刪除語法。冒號前面為空代表「用空內容推送到這個 ref」，等於刪除。\n第三步：觸發 Jenkins 重新掃描 刪完 tag 後 Jenkins 不會立刻反映變化，需要手動觸發：\n進入 Jenkins job → 點擊左側的 Scan Multibranch Pipeline Now\n如果有設定 webhook（GitLab/GitHub），push 事件會自動觸發掃描，不需要手動操作。\n不同 Repo 的清理差異 實務上三個 repo 的 tag 命名方式不同，清理策略也要調整：\nRepo Tag 格式 特徵 策略 Flutter dev-0.0.74+181 版本+build number 每版本保留最新 Strapi dev-0.212 遞增版號 全刪 / 保留最新 N 個 Vue dev-0.308 遞增版號 全刪 / 保留最新 N 個 Strapi 和 Vue 的 tag 沒有 +buildNumber 的重複問題，但 307 個遞增版號也是一種浪費。這種情況可以改用「保留最新 N 個」的策略：\n# 保留最新 10 個 dev tag，其餘刪除 git tag -l \u0026#34;dev-*\u0026#34; | sort -V \u0026gt; /tmp/all_dev.txt total=$(wc -l \u0026lt; /tmp/all_dev.txt | tr -d \u0026#39; \u0026#39;) keep=10 delete=$((total - keep)) head -${delete} /tmp/all_dev.txt \u0026gt; /tmp/tags_to_delete.txt sort -V 是版本排序（version sort），會正確處理 dev-0.9 排在 dev-0.10 前面的情況，比字典排序準確。\n實際清理成果 三個 repo 的清理結果：\nRepo 清理前 清理後 刪除數 Flutter (dev/stg/prod) 308 41 267 Strapi (dev/stg/prod) 546 40 506 Vue (dev/stg/prod) 592 41 551 合計 1446 122 1324 清完之後 Jenkins 的 Tags 頁面從一片紅海變成乾淨的清單，scan 速度也明顯加快。\n未來預防：要不要關掉 Discover Tags？ Jenkins Multibranch Pipeline 預設會開啟 Discover Tags，這是那些 tag 出現在 Jenkins 上的原因。如果你的部署流程不依賴 Jenkins 自動建置 tag，可以直接關掉：\nBranch Sources → Behaviours → Discover Tags → 設為 None\n但如果你的 CI/CD 就是靠 git tag 觸發部署（像我們用 dev-* / stg-* / prod-* tag 來區分環境），那 Discover Tags 必須保留。這種情況下，定期清理就是必要的維護工作。\n一個實用的做法是在 CI/CD pipeline 裡加一個定期清理的 job，自動刪除超過 30 天的 dev tag。這樣就不需要手動大掃除了。\n結語 Git tag 的累積是一個典型的「技術債悄悄長大」的案例——每次部署只加一個 tag，看似無害，但幾個月後就是上千個。關鍵的認知是：Jenkins 只是 Git 的鏡像，要清理鏡中的倒影，必須清理真實的物件。\n","permalink":"https://peter-notes.com/posts/jenkins-multibranch-git-tag-cleanup/","summary":"\u003ch2 id=\"前言tags-173-的紅燈海\"\u003e前言：Tags (173) 的紅燈海\u003c/h2\u003e\n\u003cp\u003e打開 Jenkins 的 Multibranch Pipeline 頁面，映入眼簾的是一整排紅色叉叉——173 個 tag，幾乎全是失敗的歷史建置記錄。這些 tag 從 \u003ccode\u003edev-0.0.74+143\u003c/code\u003e 一路排到 \u003ccode\u003edev-0.0.74+181\u003c/code\u003e，光是同一個版本就堆了 39 個。\u003c/p\u003e\n\u003cp\u003e三個 repo（Flutter、Strapi、Vue）加起來超過 1000 個 tag。這不只是視覺上的噪音，更是 Jenkins 每次 scan 都要花時間處理的負擔。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"核心觀念jenkins-上的-tag-不是-jenkins-的\"\u003e核心觀念：Jenkins 上的 Tag 不是 Jenkins 的\u003c/h2\u003e\n\u003cp\u003e第一個直覺可能是「到 Jenkins 上刪」，但這是錯的。\u003c/p\u003e\n\u003cp\u003eJenkins Multibranch Pipeline 的運作方式是定期掃描 Git repository，把發現的 branch 和 tag 當作獨立的 pipeline 來建置。換句話說，Jenkins 頁面上看到的 tag \u003cstrong\u003e就是 Git remote 上的 tag\u003c/strong\u003e——Jenkins 只是一面鏡子。\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/jenkins-multibranch-git-tag-cleanup-diagram-16b06fdd.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e\u003cstrong\u003e為什麼不能從 Jenkins 刪？\u003c/strong\u003e 因為即使你在 Jenkins UI 手動移除某個 tag 的建置記錄，下一次 scan 時 Jenkins 發現 remote 上 tag 還在，又會重新建立。要斬草除根，必須從 Git remote 下手。\u003c/p\u003e","title":"Jenkins Multibranch Pipeline 的 Tag 大掃除"},{"content":"事發：部署成功但服務掛了 一次例行的 Strapi 後端部署，Jenkins build 成功、Docker image 推上 ECR、kubectl set image 也順利執行。但 rollout 等了 300 秒後超時。\n以下是整個部署流程，可以看到問題出在最後一步：\n具體的錯誤訊息：\nerror: timed out waiting for the condition Jenkins 回報「部署失敗，已自動回滾」。奇怪的是，build 每一步都成功了，問題出在 rollout 階段。\n調查：Pod 起不來的真正原因 問題排查的過程如下，從 pod 狀態開始一路追到節點層級：\n查看 pod 狀態，發現新 pod 卡在 Pending，舊 pod 卡在 Terminating：\n$ kubectl get pods NAME READY STATUS AGE strapi-stg-5896c67c-kvrn2 0/1 Pending 88s strapi-stg-69f7c958b7-kcbc7 1/1 Terminating 44h web-stg-7bb99cfb54-x8j99 0/1 Pending 88s 直覺反應是看 pod events：\n$ kubectl describe pod strapi-stg-5896c67c-kvrn2 Events: Warning FailedScheduling 0/2 nodes are available: 1 node(s) had untolerated taint {node.kubernetes.io/unreachable: } 1 node(s) didn\u0026#39;t match Pod\u0026#39;s node affinity/selector 兩個節點都不能用。 一個 unreachable，另一個 node selector 不符（env=stg vs env=prod）。\n確認節點狀態：\n$ kubectl get nodes NAME STATUS LABELS ip-192-168-xxx NotReady env=stg # ← 掛了 ip-192-168-yyy Ready env=prod # ← 但 stg pod 排不上去 根因：磁碟空間不足 (DiskPressure) 查看 K8s events 揭露了真正的根因：\n$ kubectl get events --sort-by=\u0026#39;.lastTimestamp\u0026#39; NodeHasDiskPressure Node ip-192-168-xxx status is now: NodeHasDiskPressure NodeNotReady Node ip-192-168-xxx status is now: NodeNotReady 這個連鎖反應的完整過程：\n為什麼磁碟會滿？EKS 節點用的是 t4g.medium，配了 40G EBS。每次部署都會拉新的 container image（約 1GB），但 containerd 不會自動清理舊版 image。部署了幾十個版本後，image 快取就把磁碟塞滿了。\n這就是為什麼問題在部署時爆發——拉新 image 剛好把最後的空間用光。\n緊急恢復：Reboot 節點 恢復的流程很直接：\n當下最快的恢復方式是 reboot 節點，讓 containerd 重新整理：\n# 找到 EC2 instance $ aws ec2 describe-instances \\ --filters \u0026#34;Name=private-ip-address,Values=192.168.xxx.xxx\u0026#34; \\ --query \u0026#34;Reservations[].Instances[].InstanceId\u0026#34; [\u0026#34;i-0a1b2c3d4e5f67890\u0026#34;] # Reboot $ aws ec2 reboot-instances --instance-ids i-0a1b2c3d4e5f67890 約 2 分鐘後節點回來，pod 自動重新排程：\n$ kubectl get nodes ip-192-168-xxx Ready env=stg # ✅ 恢復了 $ kubectl get pods strapi-stg-764756c8fc-mj9vq 1/1 Running # ✅ 服務正常 Reboot 後磁碟使用量從接近 100% 降到 42%。containerd 在啟動時清理了部分暫存和未使用的層。\n預防：設定自動清理 CronJob 治標之後要治本。在 K8s 上建立 CronJob，定期用 crictl rmi --prune 清理未被任何 pod 使用的舊 image：\napiVersion: batch/v1 kind: CronJob metadata: name: image-cleanup spec: schedule: \u0026#34;0 20 * * *\u0026#34; # UTC 20:00 = 台北凌晨 4:00 jobTemplate: spec: template: spec: nodeSelector: env: stg containers: - name: cleanup image: alpine:3.19 command: - nsenter - -t - \u0026#34;1\u0026#34; - -m - -u - -i - -n - -- - sh - -c - \u0026#34;crictl rmi --prune\u0026#34; securityContext: privileged: true restartPolicy: OnFailure 關鍵設計：用 nsenter 進入 host 的 namespace，才能存取 host 上的 crictl。普通容器看不到 host 的 containerd。\n反思：為什麼沒有提早發現？ 完整的預防架構應該長這樣：\n這次事故暴露了幾個盲點：\n缺少磁碟監控：沒有設定 CloudWatch alarm 監控節點磁碟使用率。如果在 80% 就告警，就不會等到 100% 才爆炸。\n單點故障的節點架構：每個環境只有一個節點，掛了就全掛。但增加節點意味著成本翻倍，對小團隊來說需要權衡。一個折衷方案是允許 pod 在其他環境節點上當 fallback：\n# 緊急時可以讓另一個節點暫時跑 stg kubectl label node ip-192-168-yyy env=stg --overwrite Image 生命週期管理：containerd 預設不限制 image 快取大小。Kubelet 有 imageGCHighThresholdPercent（預設 85%）和 imageGCLowThresholdPercent（預設 80%）設定，理論上會自動清理。但在某些 EKS 版本上，這個機制可能沒有正確運作。\n結論 DiskPressure 是 K8s 叢集中最容易被忽略卻最致命的問題之一。每次部署都在累積 image 快取，像是定時炸彈。預防措施只需要一個 CronJob，但事後恢復可能要搶救整個環境。\n三個 takeaway：\n監控磁碟使用量，在 80% 就告警 定期清理 container image，用 CronJob 自動化 考慮 fallback 機制，單節點架構至少要有手動切換的方案 ","permalink":"https://peter-notes.com/posts/kubernetes-node-diskpressure-incident/","summary":"\u003ch2 id=\"事發部署成功但服務掛了\"\u003e事發：部署成功但服務掛了\u003c/h2\u003e\n\u003cp\u003e一次例行的 Strapi 後端部署，Jenkins build 成功、Docker image 推上 ECR、\u003ccode\u003ekubectl set image\u003c/code\u003e 也順利執行。但 rollout 等了 300 秒後超時。\u003c/p\u003e\n\u003cp\u003e以下是整個部署流程，可以看到問題出在最後一步：\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/kubernetes-node-diskpressure-incident-diagram-3db68e7c.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e具體的錯誤訊息：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eerror: timed out waiting for the condition\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eJenkins 回報「部署失敗，已自動回滾」。奇怪的是，build 每一步都成功了，問題出在 rollout 階段。\u003c/p\u003e\n\u003ch2 id=\"調查pod-起不來的真正原因\"\u003e調查：Pod 起不來的真正原因\u003c/h2\u003e\n\u003cp\u003e問題排查的過程如下，從 pod 狀態開始一路追到節點層級：\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/kubernetes-node-diskpressure-incident-diagram-06546d13.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e查看 pod 狀態，發現新 pod 卡在 \u003ccode\u003ePending\u003c/code\u003e，舊 pod 卡在 \u003ccode\u003eTerminating\u003c/code\u003e：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e$ kubectl get pods\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eNAME                              READY   STATUS        AGE\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003estrapi-stg-5896c67c-kvrn2        0/1     Pending       88s\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003estrapi-stg-69f7c958b7-kcbc7      1/1     Terminating   44h\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eweb-stg-7bb99cfb54-x8j99         0/1     Pending       88s\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e直覺反應是看 pod events：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e$ kubectl describe pod strapi-stg-5896c67c-kvrn2\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eEvents:\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  Warning  FailedScheduling  0/2 nodes are available:\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"m\"\u003e1\u003c/span\u003e node\u003cspan class=\"o\"\u003e(\u003c/span\u003es\u003cspan class=\"o\"\u003e)\u003c/span\u003e had untolerated taint \u003cspan class=\"o\"\u003e{\u003c/span\u003enode.kubernetes.io/unreachable: \u003cspan class=\"o\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"m\"\u003e1\u003c/span\u003e node\u003cspan class=\"o\"\u003e(\u003c/span\u003es\u003cspan class=\"o\"\u003e)\u003c/span\u003e didn\u003cspan class=\"s1\"\u003e\u0026#39;t match Pod\u0026#39;\u003c/span\u003es node affinity/selector\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e兩個節點都不能用。\u003c/strong\u003e 一個 unreachable，另一個 node selector 不符（\u003ccode\u003eenv=stg\u003c/code\u003e vs \u003ccode\u003eenv=prod\u003c/code\u003e）。\u003c/p\u003e","title":"Kubernetes 節點 DiskPressure 事故：部署失敗到緊急救援的完整記錄"},{"content":"前言：手動維護 53 種食物的極限 我正在開發一款健康追蹤 App，核心功能之一是飲食記錄。一開始，食物資料庫是手動維護的 JSON — 53 種台灣常見小吃，從滷肉飯到珍珠奶茶。\n53 種夠用嗎？使用者搜尋「鮭魚」找不到、搜尋「優格」找不到、搜尋「oatmeal」更不用說。手動新增不可能跟上需求，我需要一條自動化管線，把全世界的食物營養資料拉進來。\n這就是 ETL 管線登場的時機。\n什麼是 ETL？用食物資料庫解釋 ETL 是 Extract（擷取）、Transform（轉換）、Load（載入） 的縮寫，是資料工程最基礎的設計模式。與其抽象解釋，直接用這個專案的食物資料庫來看：\n四個來源的原始資料格式完全不同 — 台灣用中文欄位名（粗蛋白）、日本用 FAO 代碼（prot）、USDA 用數字編號（203）。Transform 階段把它們統一成 App 需要的 {id, name, emoji, category, nutrition} 結構。\n為什麼不直接在 App 端串接 API？因為食物營養資料是靜態的 — 雞蛋的蛋白質含量不會每天變。離線 JSON 零延遲、不吃流量、無網路也能用。ETL 只在開發階段跑一次，產出的 JSON 跟著 App 一起發布。\nUSDA API 的格式陷阱：同一個服務，兩套規則 USDA FoodData Central 是美國農業部的食物營養資料庫，涵蓋近 8,000 種食物。我選擇 foods/list endpoint 批量下載 SR Legacy 和 Foundation Foods 資料。\n然而，這個 API 有一個文件沒有明確說明的陷阱：foods/list 和 foods/search 兩個 endpoint 回傳的營養素格式完全不同。\n同一個營養素（Energy/熱量），在 foods/list 的 key 是 number: \u0026quot;208\u0026quot;，在 foods/search 是 nutrientId: 1008。不是大小寫差異、不是命名慣例不同 — 是完全不同的編號系統。\n不處理這件事的後果？解析出來的營養素全部是零。程式不會報錯，只會靜悄悄地產出一堆「0 卡路里、0 蛋白質」的食物。\n解法是維護兩套映射表（208 → calories 和 1008 → calories），在解析時先嘗試 number key，沒有的話再嘗試 nutrientId key，自動適應不同 endpoint 的回傳格式。\n教訓：串接第三方 API 時，不要假設同一個服務的不同 endpoint 會用相同的資料結構。永遠先拿真實回傳值比對文件。\n缺少分類欄位？用 NDB Number 反推 第二個坑：foods/list API 不回傳 foodCategory 欄位。\nApp 內部需要食物分類（蛋類、肉類、穀物\u0026hellip;）來決定 emoji 和預設份量。foods/search 有 foodCategory，但 foods/list 沒有。換 API？不行 — foods/search 不支援批量下載，每次只能查一個關鍵字。\n翻遍 API 文件後，我注意到每筆食物都有一個 ndbNumber（National Database Number）。這個編號的前兩碼對應 USDA 的食品群組分類：\n但這裡有一個邊界案例：USDA 把蛋類和乳品合併在群組 01（\u0026ldquo;Dairy and Egg Products\u0026rdquo;）。光靠前兩碼會把雞蛋歸為 dairy。解法是對群組 01 加一層名稱關鍵字判斷 — 名稱包含 \u0026ldquo;egg\u0026rdquo; 就歸蛋類，否則歸乳品。\n整個分類邏輯分三層優先順序：先看 NDB 群組代碼（最可靠），再看 foodCategory（若有），最後用名稱關鍵字兜底。三層 fallback 確保 8,000 種食物都能正確分類。\n這種「從現有欄位推導缺失資訊」的手法在資料工程很常見。當 API 不給你需要的欄位時，不要急著換方案 — 先看看現有資料裡有沒有可利用的線索。\n五來源合併去重：優先順序決定一切 最後一塊拼圖是合併。五個來源的資料格式已經在 Transform 階段統一了，但同一種食物可能在多個來源都出現（雞蛋在台灣食藥署、日本、USDA 都有）。\n去重策略很簡單 — 先到先贏，按優先順序載入：\n為什麼台灣小吃最優先？因為它是手動校正過的 — 「滷肉飯」的營養數據比 USDA 的 \u0026ldquo;Braised pork rice\u0026rdquo; 更精準，份量也更符合台灣的一碗標準。\n去重用正規化名稱比對 — 移除括號內容（中英文括號），確保「雞蛋」和「雞蛋（生）」不會被視為不同食物。合併完成後，管線自動驗證三件事：無重複 ID、無重複名稱、所有食物都有完整 8 個營養素欄位。\n優先順序 來源 處理後筆數 去重後新增 1 台灣小吃（手動） 53 52 2 台灣食藥署 2,176 1,813 3 日本食品標準成分表 2,466 2,450 4 中國食物成分表 1,623 1,076 5 USDA FoodData Central 7,853 7,761 總計 13,152 結語：ETL 管線的長期價值 從 53 種到 13,152 種，這條管線讓食物資料庫擴充了 248 倍。但更重要的是可重複性 — 當台灣食藥署更新資料、或我想新增韓國來源時，只需要加一個 process_korea_food.py，跑一次 python run_pipeline.py --all，就能無痛擴充。\n三個帶走的教訓：\n同一個 API 的不同 endpoint 格式可能不同 — 永遠用真實回傳值驗證 缺少欄位不代表無解 — 從現有資料反推，NDB Number 就是藏在資料裡的分類密碼 合併優先順序比演算法重要 — 在地資料優先，品質勝過數量 ","permalink":"https://peter-notes.com/posts/usda-fooddata-etl-pipeline-design/","summary":"\u003ch2 id=\"前言手動維護-53-種食物的極限\"\u003e前言：手動維護 53 種食物的極限\u003c/h2\u003e\n\u003cp\u003e我正在開發一款健康追蹤 App，核心功能之一是飲食記錄。一開始，食物資料庫是手動維護的 JSON — 53 種台灣常見小吃，從滷肉飯到珍珠奶茶。\u003c/p\u003e\n\u003cp\u003e53 種夠用嗎？使用者搜尋「鮭魚」找不到、搜尋「優格」找不到、搜尋「oatmeal」更不用說。手動新增不可能跟上需求，我需要一條自動化管線，把全世界的食物營養資料拉進來。\u003c/p\u003e\n\u003cp\u003e這就是 ETL 管線登場的時機。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"什麼是-etl用食物資料庫解釋\"\u003e什麼是 ETL？用食物資料庫解釋\u003c/h2\u003e\n\u003cp\u003eETL 是 \u003cstrong\u003eExtract（擷取）、Transform（轉換）、Load（載入）\u003c/strong\u003e 的縮寫，是資料工程最基礎的設計模式。與其抽象解釋，直接用這個專案的食物資料庫來看：\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/usda-fooddata-etl-pipeline-design-diagram-22bec45a.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e四個來源的原始資料格式完全不同 — 台灣用中文欄位名（\u003ccode\u003e粗蛋白\u003c/code\u003e）、日本用 FAO 代碼（\u003ccode\u003eprot\u003c/code\u003e）、USDA 用數字編號（\u003ccode\u003e203\u003c/code\u003e）。Transform 階段把它們統一成 App 需要的 \u003ccode\u003e{id, name, emoji, category, nutrition}\u003c/code\u003e 結構。\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e為什麼不直接在 App 端串接 API？因為食物營養資料是\u003cstrong\u003e靜態的\u003c/strong\u003e — 雞蛋的蛋白質含量不會每天變。離線 JSON 零延遲、不吃流量、無網路也能用。ETL 只在開發階段跑一次，產出的 JSON 跟著 App 一起發布。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"usda-api-的格式陷阱同一個服務兩套規則\"\u003eUSDA API 的格式陷阱：同一個服務，兩套規則\u003c/h2\u003e\n\u003cp\u003eUSDA FoodData Central 是美國農業部的食物營養資料庫，涵蓋近 8,000 種食物。我選擇 \u003ccode\u003efoods/list\u003c/code\u003e endpoint 批量下載 SR Legacy 和 Foundation Foods 資料。\u003c/p\u003e\n\u003cp\u003e然而，這個 API 有一個\u003cstrong\u003e文件沒有明確說明的陷阱\u003c/strong\u003e：\u003ccode\u003efoods/list\u003c/code\u003e 和 \u003ccode\u003efoods/search\u003c/code\u003e 兩個 endpoint 回傳的營養素格式完全不同。\u003c/p\u003e","title":"從 53 種食物到 13,152 種：五國食物資料庫 ETL 管線設計實錄"},{"content":"那個怎麼都消不掉的警告 Google Play Console 上掛著一則警告：「App Bundle 含有原生程式碼，而您尚未上傳偵錯符號檔」。\n先釐清幾個名詞，因為 Google Play 的警告訊息把不同的東西混在一起講：\n檔案 用途 來源 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。\n整體流程：Build → Detect → Upload 在看個別陷阱前，先理解正確的 CI 流程應該長什麼樣：\n關鍵原則：上傳邏輯綁定 build 產物，不綁定部署環境。只要 build 裡有 .so 或 mapping.txt，就上傳。不管是 staging 還是 production。\n但從這個目標到實際跑通，踩了三個完全不同性質的坑。\n陷阱一：File.expand_path 的基準不是你以為的那個 第一版的 Fastfile 寫死了路徑：\n# android/fastlane/Fastfile 裡 intermediates = File.expand_path(\u0026#34;../build/app/intermediates\u0026#34;) CI log 印出：\nIntermediates path: /workspace/android/build/app/intermediates (exists: false) 路徑多了一層 android/。問題出在 Fastlane 的雙重路徑語境：\n同一個 ../build/，在 supply 裡指向專案根目錄的 build/，在 File.expand_path 裡卻指向 android/build/。而且 Fastlane 在不同 action 之間可能用 Dir.chdir 切換工作目錄，.. 的意義會跟著飄。\n修正：用 __dir__ 當絕對基準。\n__dir__ 是 Ruby 的內建常數，回傳「這個檔案本身所在目錄」的絕對路徑。跟 Dir.pwd（當前工作目錄）不同，__dir__ 不會因為 Dir.chdir 而改變——Fastfile 放在 android/fastlane/，__dir__ 就永遠是 android/fastlane/ 的絕對路徑。\n# __dir__ = /workspace/android/fastlane（固定不變） # ../../ 往上兩層 = /workspace（專案根目錄） project_root = File.expand_path(\u0026#34;../../\u0026#34;, __dir__) intermediates = File.join(project_root, \u0026#34;build/app/intermediates\u0026#34;) 不管是 Jenkins、本機、還是其他 CI 環境，__dir__ 是唯一不會變的錨點。\n陷阱二：mapping 和 mapping_paths 互斥 供應 Google Play 需要兩種檔案，直覺上分開傳：\n# ❌ 直覺但錯誤的寫法 supply( mapping: \u0026#34;build/.../mapping.txt\u0026#34;, mapping_paths: [\u0026#34;native-debug-symbols.zip\u0026#34;] ) Fastlane 直接報錯：Unresolved conflict between options: 'mapping' and 'mapping_paths'\nmapping 是早期的單檔參數，mapping_paths 是後來為了多檔設計的陣列參數。兩者互斥。\n修正：全部統一用 mapping_paths 陣列。\n# ✅ 統一用陣列，不管幾個檔案都往裡面塞 upload_paths = [] upload_paths \u0026lt;\u0026lt; symbols_zip if File.exist?(symbols_zip) upload_paths += Dir.glob(\u0026#34;#{project_root}/build/**/mapping.txt\u0026#34;) supply(aab: aab_path, mapping_paths: upload_paths) 未來不管加多少 deobfuscation artifacts（NDK symbols、Firebase Crashlytics 等），都往同一個陣列塞，不用改參數型態。\n陷阱三：Symbols 邏輯綁在環境，不是綁在產物 最初只在 deploy_prod_to_google_play 加了 symbols 上傳，staging 沒有。邏輯是：「只有正式版需要 crash 分析。」\n但 staging 一樣上傳到 Google Play（internal testing track），build 裡一樣含 .so。結果 staging 每個版本都掛著警告，而且 native crash 的堆疊追蹤完全無法還原。\n修正：抽出共用方法，用 Dir.glob 動態偵測。\ndef zip_native_symbols_if_exist project_root = File.expand_path(\u0026#34;../../\u0026#34;, __dir__) so_files = Dir.glob(\u0026#34;#{project_root}/build/**/lib/**/*.so\u0026#34;) # 找到就打包，找不到就跳過 end 為什麼用 Dir.glob 而不寫死路徑？因為 .so 檔的目錄結構隨 Android Gradle Plugin（AGP）版本變化。AGP 7.x 和 8.x 的 intermediates 子目錄名稱不同。寫死路徑就是綁死 AGP 版本，下次升級又會壞。\n實測驗證 版本 Native Symbols R8 Mapping mapping_paths Google Play 警告 2026000034 寫死路徑 無 [] 兩個警告 2026000036 __dir__ + glob 無 [symbols.zip] 剩一個警告 2026000037 有 mapping + mapping_paths 衝突 build 失敗 - 2026000038 有 有，統一用 mapping_paths [symbols.zip, mapping.txt] 零警告 從第一版到零警告，改了四個版本。每個版本都是不同性質的問題：路徑解析、API 參數衝突、架構設計。\n回顧：三個陷阱的共同本質 三個坑看起來毫不相關，但本質是同一件事：不要假設，要偵測。\n不要假設路徑 → 用 __dir__ 固定基準 不要假設參數相容 → 統一用 mapping_paths 不要假設哪個環境需要什麼 → 讓 build 產物自己說話 讓 CI 根據實際的 build artifacts 決定行為，而不是根據 lane 名稱或環境變數。這個區別決定了 CI 架構是「能用」還是「能維護」。\n","permalink":"https://peter-notes.com/posts/fastlane-native-symbols-google-play/","summary":"\u003ch2 id=\"那個怎麼都消不掉的警告\"\u003e那個怎麼都消不掉的警告\u003c/h2\u003e\n\u003cp\u003eGoogle Play Console 上掛著一則警告：「App Bundle 含有原生程式碼，而您尚未上傳偵錯符號檔」。\u003c/p\u003e\n\u003cp\u003e先釐清幾個名詞，因為 Google Play 的警告訊息把不同的東西混在一起講：\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e檔案\u003c/th\u003e\n          \u003cth\u003e用途\u003c/th\u003e\n          \u003cth\u003e來源\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eNative Debug Symbols\u003c/strong\u003e (.so)\u003c/td\u003e\n          \u003ctd\u003e還原 C/C++ native crash 的堆疊追蹤\u003c/td\u003e\n          \u003ctd\u003eFlutter 引擎、NDK、第三方 SDK\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eR8 Mapping\u003c/strong\u003e (mapping.txt)\u003c/td\u003e\n          \u003ctd\u003e還原 Java/Kotlin 被 R8 混淆後的類別名稱\u003c/td\u003e\n          \u003ctd\u003eGradle build（\u003ccode\u003eminifyEnabled true\u003c/code\u003e）\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e兩者都要上傳，Google Play 才不會抱怨。Flutter 專案兩者都需要：引擎帶了 \u003ccode\u003e.so\u003c/code\u003e，Gradle 開了 R8。\u003c/p\u003e\n\u003ch2 id=\"整體流程build--detect--upload\"\u003e整體流程：Build → Detect → Upload\u003c/h2\u003e\n\u003cp\u003e在看個別陷阱前，先理解正確的 CI 流程應該長什麼樣：\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/fastlane-native-symbols-google-play-diagram-d0dc55ee.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e關鍵原則：\u003cstrong\u003e上傳邏輯綁定 build 產物，不綁定部署環境\u003c/strong\u003e。只要 build 裡有 \u003ccode\u003e.so\u003c/code\u003e 或 \u003ccode\u003emapping.txt\u003c/code\u003e，就上傳。不管是 staging 還是 production。\u003c/p\u003e\n\u003cp\u003e但從這個目標到實際跑通，踩了三個完全不同性質的坑。\u003c/p\u003e\n\u003ch2 id=\"陷阱一fileexpand_path-的基準不是你以為的那個\"\u003e陷阱一：\u003ccode\u003eFile.expand_path\u003c/code\u003e 的基準不是你以為的那個\u003c/h2\u003e\n\u003cp\u003e第一版的 Fastfile 寫死了路徑：\u003c/p\u003e","title":"Google Play 警告消不掉：Fastlane 上傳 Native Debug Symbols 的三個陷阱"},{"content":"結帳頁突然報錯：Missing or invalid credentials 使用者在 App 內開啟網頁版結帳頁，輸入折扣碼後收到一個刺眼的錯誤訊息：「Missing or invalid credentials」。\n不是每個人都遇到，也不是每次都發生。但只要在頁面上停留超過一段時間再操作，就一定會觸發。\n聽起來很熟悉？沒錯，這跟前一篇文章的問題同源——JWT 15 分鐘過期。差別在於，上次是後端的 refresh API 被權限系統擋掉，這次是前端根本沒有呼叫 refresh 的能力。\n架構背景：App 開網頁的 SSO 流程 我們的 Flutter App 會透過 in-app browser 開啟 Vue 網頁。token 的傳遞方式很直接：\nhttps://web.example.com/checkout/xxx?token=eyJhbG... Vue 從 URL query param 取得 JWT，存進 sessionStorage，之後的 API 呼叫都帶這個 token。\n問題在於：只傳了 JWT，沒有傳 refresh token。\nJWT 有 15 分鐘的壽命。使用者開啟頁面、慢慢填寫發票資料、比較方案——15 分鐘一晃就過了。接下來任何 API 呼叫都會收到 401，但 Vue 手上沒有 refresh token，無法換新的 JWT。只能眼睜睜看著使用者被擋在門外。\n為什麼 Flutter 不需要擔心，但 Vue 必須處理 Flutter App 本身有完整的 token refresh 機制：interceptor 攔截 401、用 refresh token 換新 JWT、重送失敗的 request。整個流程無縫，使用者無感。\n但 Vue 網頁是透過 SSO 被「借」來用的——它的認證完全依賴 App 給的那一把 JWT。一旦過期，就像拿著一張過期的通行證，沒有任何補救手段。\n這是跨平台 SSO 整合常見的盲點：主應用的認證能力不會自動延伸到被開啟的網頁。\n修復策略：完整的 Token 續約鏈路 修復分成兩端：Flutter 負責「給」，Vue 負責「用」。\nFlutter 端：多傳一個 refreshToken 改動極小，在組裝 URL 時從 TokenService 多取一個 refresh token：\nfinal token = await tokenService.getToken(); final refreshToken = await tokenService.getRefreshToken(); queryParams[\u0026#39;token\u0026#39;] = token; if (refreshToken != null) { queryParams[\u0026#39;refreshToken\u0026#39;] = refreshToken; } // URL: ?token=eyJ...\u0026amp;refreshToken=abc123... 就這樣。Flutter 端的改動只有 3 行。\nVue 端：從零建立 Refresh 機制 Vue 端才是重頭戲，需要補上四個環節。\n1. 接收並儲存 refresh token\n原本 tokenHandler.js 只處理 token 參數，現在要一併提取 refreshToken：\nconst refreshToken = urlParams.get(\u0026#39;refreshToken\u0026#39;) if (refreshToken) { sessionStorage.setItem(\u0026#39;RefreshToken\u0026#39;, refreshToken) } 2. 集中式的 refresh 邏輯\n新增 authService.js，核心是 refreshAccessToken()。這裡有個關鍵設計——Completer 模式防止並發 refresh：\nlet refreshPromise = null export const refreshAccessToken = async () =\u0026gt; { // 如果已經有 refresh 進行中，共用同一個 Promise if (refreshPromise) return refreshPromise refreshPromise = _doRefresh() try { return await refreshPromise } finally { refreshPromise = null } } 為什麼要防並發？想像使用者的頁面同時發出 3 個 API 請求，全部收到 401。如果每個都獨立觸發 refresh，會打出 3 次 /auth/refresh——其中 2 次會因為 refresh token 已經被第一次用掉（one-time use）而失敗。Completer 模式確保同一時刻只有一次 refresh 在執行，其他呼叫者共用結果。\n3. Axios interceptor 自動重送\n新增 axiosInstance.js，在 response interceptor 攔截 401：\naxiosInstance.interceptors.response.use( (response) =\u0026gt; response, async (error) =\u0026gt; { const originalRequest = error.config if (error.response?.status === 401 \u0026amp;\u0026amp; !originalRequest._retry) { originalRequest._retry = true // 防止無限迴圈 const newJwt = await refreshAccessToken() originalRequest.headers.Authorization = `Bearer ${newJwt}` return axiosInstance(originalRequest) // 用新 token 重送 } return Promise.reject(error) } ) _retry flag 是另一個重要細節：如果 refresh 本身也失敗（比如 refresh token 也過期了），不會陷入無限重試。\n4. Apollo Client 改為動態取 token\n原本的 Apollo Client 在建立時就把 JWT 寫死在 header：\n// 之前：JWT 在建立時就固定了，之後 refresh 了也不會更新 const userToken = new ApolloClient({ uri: GRAPHQL_API_URL, headers: { authorization: `Bearer ${JWTtoken}` } }) 改成用 setContext 動態取值，每次 request 都從 sessionStorage 讀最新的 JWT：\nconst authLink = setContext((_, { headers }) =\u0026gt; ({ headers: { ...headers, authorization: sessionStorage.getItem(\u0026#39;LoginJWT\u0026#39;) ? `Bearer ${sessionStorage.getItem(\u0026#39;LoginJWT\u0026#39;)}` : \u0026#39;\u0026#39; } })) 實測驗證：等 15 分鐘看它自己活過來 在 STG 環境跑了完整的端對端測試：\n時間點 動作 結果 0:00 登入取得 JWT + refresh token JWT exp = 15 min 16:04 用原始 JWT 呼叫 API 401 — 確認過期 16:04 用 refresh token 換新 JWT 成功 — 取得新 JWT 16:04 用新 JWT 呼叫 API 200 — 正常回應 整個 refresh 過程在 interceptor 裡自動完成，使用者完全無感。\n回顧：跨平台 SSO 的 Token 設計清單 這次的教訓可以濃縮成一個檢查清單，適用於任何「主應用開網頁」的 SSO 場景：\n傳遞層：不只傳 access token，refresh token 也要一起帶 儲存層：網頁端要有明確的 token 儲存策略（sessionStorage / cookie） 續約層：必須有 401 interceptor + 自動 refresh + 重送機制 防護層：Completer 模式防並發 refresh、_retry flag 防無限迴圈 GraphQL 層：Apollo Client 用 setContext 動態取 token，不要寫死 少了任何一層，使用者就會在某個不可預測的時間點被打斷。而「不可預測」是使用者體驗中最糟糕的特質。\n","permalink":"https://peter-notes.com/posts/vue-jwt-refresh-token-flutter-sso/","summary":"\u003ch2 id=\"結帳頁突然報錯missing-or-invalid-credentials\"\u003e結帳頁突然報錯：Missing or invalid credentials\u003c/h2\u003e\n\u003cp\u003e使用者在 App 內開啟網頁版結帳頁，輸入折扣碼後收到一個刺眼的錯誤訊息：「Missing or invalid credentials」。\u003c/p\u003e\n\u003cp\u003e不是每個人都遇到，也不是每次都發生。但只要在頁面上停留超過一段時間再操作，就一定會觸發。\u003c/p\u003e\n\u003cp\u003e聽起來很熟悉？沒錯，這跟\u003ca href=\"/posts/strapi-v5-auth-refresh-route-pitfall/\"\u003e前一篇文章\u003c/a\u003e的問題同源——JWT 15 分鐘過期。差別在於，上次是後端的 refresh API 被權限系統擋掉，這次是\u003cstrong\u003e前端根本沒有呼叫 refresh 的能力\u003c/strong\u003e。\u003c/p\u003e\n\u003ch2 id=\"架構背景app-開網頁的-sso-流程\"\u003e架構背景：App 開網頁的 SSO 流程\u003c/h2\u003e\n\u003cp\u003e我們的 Flutter App 會透過 in-app browser 開啟 Vue 網頁。token 的傳遞方式很直接：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ehttps://web.example.com/checkout/xxx?token=eyJhbG...\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eVue 從 URL query param 取得 JWT，存進 \u003ccode\u003esessionStorage\u003c/code\u003e，之後的 API 呼叫都帶這個 token。\u003c/p\u003e\n\u003cp\u003e問題在於：\u003cstrong\u003e只傳了 JWT，沒有傳 refresh token\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003eJWT 有 15 分鐘的壽命。使用者開啟頁面、慢慢填寫發票資料、比較方案——15 分鐘一晃就過了。接下來任何 API 呼叫都會收到 401，但 Vue 手上沒有 refresh token，無法換新的 JWT。只能眼睜睜看著使用者被擋在門外。\u003c/p\u003e\n\u003ch2 id=\"為什麼-flutter-不需要擔心但-vue-必須處理\"\u003e為什麼 Flutter 不需要擔心，但 Vue 必須處理\u003c/h2\u003e\n\u003cp\u003eFlutter App 本身有完整的 token refresh 機制：interceptor 攔截 401、用 refresh token 換新 JWT、重送失敗的 request。整個流程無縫，使用者無感。\u003c/p\u003e","title":"後端修好了，前端卻沒跟上：Vue 網頁版的 JWT Refresh 機制從零補上"},{"content":"症狀：部署成功，程式碼卻是舊的 一個新功能已經 commit 並推上 Git，Jenkins pipeline 顯示建置成功、部署完成，Kubernetes Pod 也順利啟動。進入 Pod 檢查——程式碼還是舊的。\n這不是網路延遲、不是 image pull policy、不是 tag 衝突。問題藏在 Dockerfile 裡一行看起來「很聰明」的快取優化。\n背景知識：Docker 建置的關鍵概念 在進入除錯過程之前，先釐清幾個 Docker 建置中的核心概念：\nDocker Image 與 Layer（映像檔與分層）\nDocker 映像檔由多個唯讀的「層」（layer）堆疊而成。Dockerfile 裡的每一條指令（如 COPY、RUN）都會產生一層。Docker 會對每一層計算 hash，下次建置時如果指令和輸入都沒變，就直接重用該層——這就是 layer cache。\nBuildKit\nBuildKit 是 Docker 18.09 之後引入的新一代建置引擎（透過 DOCKER_BUILDKIT=1 啟用）。相比傳統引擎，BuildKit 支援平行建置、更聰明的快取策略，以及本文主角——--mount=type=cache（cache mount）語法。\nCache Mount（--mount=type=cache）\nBuildKit 專有的功能。它在 RUN 指令執行期間，將一個持久化的目錄掛載到容器內的指定路徑。這個目錄由 BuildKit 管理，不會被寫入最終的 image layer，但內容會跨越不同次的 docker build 保留下來。常見用途是快取套件管理器的下載目錄（如 yarn cache），避免每次建置都重新下載。\nInline Cache 與 --cache-from\n另一種 BuildKit 快取策略。透過 --build-arg BUILDKIT_INLINE_CACHE=1 把快取 metadata 嵌入產出的 image，再用 --cache-from 從遠端 registry 拉取。這讓不同機器（例如 CI runner）也能共享建置快取。\n--no-cache 旗標\nDocker build 的選項，告訴 Docker 不要使用 layer cache，強制每一層都重新執行。但注意——它只作用於 layer cache，對 cache mount 的持久化儲存完全無效。\n除錯過程：四次部署才找到真正元兇 以下是從 stg-1.27 到 stg-1.30 的除錯路徑，每一次都以為找到了問題，結果只是剝開了下一層：\n第一次失敗：Layer Cache 全部命中 Jenkins build log 顯示所有 Docker layer 都是 CACHED，包括 COPY . . 這個應該偵測到檔案變更的指令。原本使用的 --cache-from 搭配 inline cache 機制，理論上會比對每一層的內容。但實際上，inline cache 的 metadata 有時不會正確追蹤 COPY 指令涉及的檔案變更，導致 Docker 誤判「這層沒變」。\n修正：移除 --cache-from，改用 --no-cache。\n第二次失敗：\u0026ndash;no-cache 跑了，但還是舊的 加了 --no-cache 之後，build log 確認 COPY . . 和 yarn build 都確實重新執行了（不再顯示 CACHED）。yarn build 花了 19 秒，看起來一切正常。\n但進入新部署的 Pod，admin panel 的 bundle 仍然是舊版本。\n這完全違反直覺——如果 COPY 和 yarn build 都重新跑了，怎麼可能產出舊的 build artifacts？\n關鍵發現：第三層快取 問題出在 Dockerfile 的這一行：\n# 修改前：使用 BuildKit cache mount 加速 build RUN --mount=type=cache,target=/opt/app/.strapi,id=strapi-cache,uid=1000,gid=1000 \\ yarn build .strapi/ 目錄是 Strapi 存放 admin panel build artifacts 的位置。--mount=type=cache 會在 build 過程中掛載一個持久化的快取卷到指定路徑。\n根本原因：Docker 快取的三個層次 這次除錯揭露了 Docker 建置過程中三種完全不同的快取機制，各自獨立運作：\n1. Layer Cache（指令層快取） Docker 預設的快取機制。每個 RUN、COPY 指令都是一層，Docker 會比對指令內容和輸入檔案的 hash，決定是否重用快取。\n--no-cache 停用的就是這層。\n2. Inline Cache（遠端快取 metadata） 透過 --build-arg BUILDKIT_INLINE_CACHE=1 將快取 metadata 寫入 image，搭配 --cache-from 從遠端 registry 拉取快取。這讓不同機器也能共享快取。\n問題：inline cache 對 COPY . . 的檔案變更偵測可能不精確，導致誤判。\n3. Cache Mount（持久化卷快取） --mount=type=cache 建立的是 BuildKit 管理的持久化儲存空間，跨 build 存活，完全獨立於 image layer 之外。\n--no-cache 對它完全無效。\n這就是問題的核心。即使 yarn build 確實重新執行了，由於 .strapi/ 被掛載了舊的 cache mount，Strapi 的 build 工具偵測到裡面已有編譯結果，認為不需要重新編譯 admin panel，直接使用了舊的 artifacts。\n用表格整理這三層的差異：\n快取類型 作用對象 --no-cache 能清除？ 跨 build 存活？ Layer Cache Docker 指令層 ✅ 能 是 Inline Cache 遠端 registry metadata ✅ 能（不用 \u0026ndash;cache-from 即可） 是 Cache Mount BuildKit 持久化卷 ❌ 不能 是 用圖表來看 --no-cache 的作用範圍，就更清楚為什麼它救不了我們：\n修復：移除 Cache Mount 修復方式出乎意料地簡單——移除 .strapi 目錄的 cache mount：\n# 修改後：直接 build，不使用 cache mount RUN yarn build 不過，這代表每次建置都要完整跑一次 Strapi admin 的 webpack 打包（約 60-90 秒）。在我們的場景中，部署頻率大約每天 1-2 次，這點建置時間完全可以接受。正確性永遠比速度重要。\n什麼時候該用 Cache Mount？ Cache mount 並非一無是處。它在以下場景很有用：\n套件管理快取：/root/.npm、/root/.yarn-cache 等路徑，存放的是不可變的套件壓縮檔，不會有「舊版本殘留」的問題 編譯器快取：如 Go 的 GOMODCACHE，快取的是 module 而非最終產物 不影響最終產物的中間快取：關鍵在於——快取內容不會直接成為 image 的一部分 不該用 cache mount 的場景：\nBuild artifacts 目錄（如 .strapi/、dist/、.next/） 任何會影響最終建置產出的目錄 當建置工具會根據快取目錄的內容決定是否跳過編譯時 教訓 --no-cache 不是萬能的。它只停用 Docker layer cache，對 --mount=type=cache 的持久化卷毫無作用。把 build artifacts 目錄掛載為 cache mount，等於把定時炸彈藏在 CI pipeline 裡。\n快取的代價是正確性風險。快取永遠是在「速度」和「正確性」之間做取捨。在 CI/CD 場景中，一次部署錯誤的成本（除錯時間 + 影響範圍）遠大於省下的幾十秒建置時間。\n當 build 結果不符預期，檢查所有快取層。不要假設「我已經清了快取」——先確認你清的是哪一層。\n本文記錄了從 stg-1.27 到 stg-1.30 的四次部署嘗試，最終在移除一行 Dockerfile 指令後解決問題。\n","permalink":"https://peter-notes.com/posts/docker-buildkit-cache-mount-stale-build/","summary":"\u003ch2 id=\"症狀部署成功程式碼卻是舊的\"\u003e症狀：部署成功，程式碼卻是舊的\u003c/h2\u003e\n\u003cp\u003e一個新功能已經 commit 並推上 Git，Jenkins pipeline 顯示建置成功、部署完成，Kubernetes Pod 也順利啟動。進入 Pod 檢查——程式碼還是舊的。\u003c/p\u003e\n\u003cp\u003e這不是網路延遲、不是 image pull policy、不是 tag 衝突。問題藏在 Dockerfile 裡一行看起來「很聰明」的快取優化。\u003c/p\u003e\n\u003ch2 id=\"背景知識docker-建置的關鍵概念\"\u003e背景知識：Docker 建置的關鍵概念\u003c/h2\u003e\n\u003cp\u003e在進入除錯過程之前，先釐清幾個 Docker 建置中的核心概念：\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eDocker Image 與 Layer（映像檔與分層）\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eDocker 映像檔由多個唯讀的「層」（layer）堆疊而成。Dockerfile 裡的每一條指令（如 \u003ccode\u003eCOPY\u003c/code\u003e、\u003ccode\u003eRUN\u003c/code\u003e）都會產生一層。Docker 會對每一層計算 hash，下次建置時如果指令和輸入都沒變，就直接重用該層——這就是 \u003cstrong\u003elayer cache\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eBuildKit\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eBuildKit 是 Docker 18.09 之後引入的新一代建置引擎（透過 \u003ccode\u003eDOCKER_BUILDKIT=1\u003c/code\u003e 啟用）。相比傳統引擎，BuildKit 支援平行建置、更聰明的快取策略，以及本文主角——\u003ccode\u003e--mount=type=cache\u003c/code\u003e（cache mount）語法。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eCache Mount（\u003ccode\u003e--mount=type=cache\u003c/code\u003e）\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eBuildKit 專有的功能。它在 \u003ccode\u003eRUN\u003c/code\u003e 指令執行期間，將一個\u003cstrong\u003e持久化的目錄\u003c/strong\u003e掛載到容器內的指定路徑。這個目錄由 BuildKit 管理，不會被寫入最終的 image layer，但\u003cstrong\u003e內容會跨越不同次的 docker build 保留下來\u003c/strong\u003e。常見用途是快取套件管理器的下載目錄（如 \u003ccode\u003eyarn cache\u003c/code\u003e），避免每次建置都重新下載。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eInline Cache 與 \u003ccode\u003e--cache-from\u003c/code\u003e\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e另一種 BuildKit 快取策略。透過 \u003ccode\u003e--build-arg BUILDKIT_INLINE_CACHE=1\u003c/code\u003e 把快取 metadata 嵌入產出的 image，再用 \u003ccode\u003e--cache-from\u003c/code\u003e 從遠端 registry 拉取。這讓不同機器（例如 CI runner）也能共享建置快取。\u003c/p\u003e","title":"Docker BuildKit Cache Mount 的隱形陷阱"},{"content":"問題現象：Xcode 正常，冷啟動閃退 最近在開發 Flutter iOS App 時遇到一個詭異的問題：透過 Xcode 按下 Run 按鈕啟動 App 完全正常，但當我停止 Debugger、從多工畫面滑掉 App、再從主畫面點擊圖示重新開啟時，App 只顯示 Launch Screen 就立刻閃退。\n這個問題讓我走了不少彎路，最後發現答案簡單得令人意外。\n誤導方向：UIScene 與 Plugin 註冊 由於 Xcode 26 開始顯示 UIScene lifecycle will soon be required 警告，我最初懷疑是 UIScene 遷移不完整導致的問題。接著又懷疑是 Plugin 註冊的 race condition，甚至在 GeneratedPluginRegistrant.m 中加入大量 debug log 來追蹤每個 Plugin 的註冊狀態。\n嘗試過的「修復」包括：\n完整實作 SceneDelegate 與 FlutterSceneDelegate 在 Flutter Engine 初始化前加入 0.5 秒延遲 逐一排除可能有問題的 Plugin 這些方向全部是錯的。\n找到根本原因：iOS Console Log 關鍵轉折點是查看 iOS 的 Console log。使用 Xcode 的 Devices and Simulators \u0026gt; Open Console 或 macOS 的 Console.app 連接 iPhone，重現冷啟動閃退後，看到了這段關鍵訊息：\n[ERROR:flutter/runtime/ptrace_check.cc(75)] Could not call ptrace(PT_TRACE_ME): Operation not permitted Cannot create a FlutterEngine instance in debug mode without Flutter tooling or Xcode. To launch in debug mode in iOS 14+, run flutter run from Flutter tools, run from an IDE with a Flutter IDE plugin or run the iOS project from Xcode. Alternatively profile and release mode apps can be launched from the home screen. 真相大白：這根本不是程式碼問題，而是 iOS 14+ 的安全機制。\n技術原理：ptrace 與 Debug 模式 為什麼 Debug 模式需要 Debugger？ iOS 14 開始，Apple 強化了 ptrace 系統呼叫的權限控制。ptrace 是 Unix 系統用於程序追蹤和除錯的機制，Debug 模式的 Flutter App 依賴它來實現：\nHot Reload / Hot Restart：即時更新程式碼 Dart VM 連接：與 Flutter DevTools 通訊 斷點除錯：暫停執行、檢查變數 當 App 嘗試呼叫 ptrace(PT_TRACE_ME) 但沒有 Debugger 連接時，iOS 會拒絕這個請求並終止 App。\n不同建置模式的差異 建置模式 執行方式 ptrace 需求 可獨立運行 Debug JIT 編譯 需要 ❌ Profile AOT 編譯 + 效能追蹤 不需要 ✅ Release AOT 編譯 + 最佳化 不需要 ✅ Debug 模式使用 JIT（Just-In-Time）編譯，需要 Dart VM 持續運行並與開發工具通訊。Release 模式則是 AOT（Ahead-Of-Time）編譯成原生機器碼，完全不需要 Dart VM。\n解決方案：使用 Release 模式 如果需要在沒有 Xcode 連接的情況下測試 App（例如給 QA 測試或自己日常使用），必須使用 Release 模式建置：\n# 建置 Release 版本 flutter build ios --release # 或使用 Profile 模式（保留部分除錯資訊） flutter build ios --profile 建置完成後，可以透過 Xcode 安裝到裝置：\n# 使用 devicectl 安裝（Xcode 15+） xcrun devicectl device install app \\ --device \u0026lt;device-id\u0026gt; \\ build/ios/iphoneos/Runner.app 為什麼容易踩到這個坑？ 這個問題之所以難以發現，是因為開發流程中的盲點：\n開發時總是透過 Xcode 或 flutter run 啟動：Debugger 自動連接，問題不會發生 測試時也是如此：很少有人會特意停止 Debugger 後再測試冷啟動 錯誤訊息不會出現在 Xcode Console：必須查看 iOS 系統 Console 才看得到 經驗法則：當 iOS App 出現「Xcode 正常但獨立運行異常」的情況，第一步應該查看 iOS Console log，而不是猜測程式碼問題。\n結論 問題 答案 為什麼要用 Release 模式？ iOS 14+ 的 ptrace 安全限制，Debug 模式必須有 Debugger 連接 不這樣做會怎樣？ 每次冷啟動都會閃退，只能透過 Xcode 啟動 未來如何避免？ 分發測試版本時一律使用 Release 或 Profile 模式 這個問題的答案雖然簡單，但診斷過程卻充滿了誤導。記住：Flutter Debug build 在 iOS 上無法獨立運行，這是設計如此，不是 bug。\n參考資源 Flutter iOS Deployment Apple TN3187: Migrating to the UIKit scene-based life cycle Flutter Engine ptrace_check.cc ","permalink":"https://peter-notes.com/posts/flutter-ios-cold-start-crash-debug-mode/","summary":"\u003ch2 id=\"問題現象xcode-正常冷啟動閃退\"\u003e問題現象：Xcode 正常，冷啟動閃退\u003c/h2\u003e\n\u003cp\u003e最近在開發 Flutter iOS App 時遇到一個詭異的問題：透過 Xcode 按下 Run 按鈕啟動 App 完全正常，但當我停止 Debugger、從多工畫面滑掉 App、再從主畫面點擊圖示重新開啟時，App 只顯示 Launch Screen 就立刻閃退。\u003c/p\u003e\n\u003cp\u003e這個問題讓我走了不少彎路，最後發現答案簡單得令人意外。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"誤導方向uiscene-與-plugin-註冊\"\u003e誤導方向：UIScene 與 Plugin 註冊\u003c/h2\u003e\n\u003cp\u003e由於 Xcode 26 開始顯示 \u003ccode\u003eUIScene lifecycle will soon be required\u003c/code\u003e 警告，我最初懷疑是 UIScene 遷移不完整導致的問題。接著又懷疑是 Plugin 註冊的 race condition，甚至在 \u003ccode\u003eGeneratedPluginRegistrant.m\u003c/code\u003e 中加入大量 debug log 來追蹤每個 Plugin 的註冊狀態。\u003c/p\u003e\n\u003cp\u003e嘗試過的「修復」包括：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e完整實作 \u003ccode\u003eSceneDelegate\u003c/code\u003e 與 \u003ccode\u003eFlutterSceneDelegate\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e在 Flutter Engine 初始化前加入 0.5 秒延遲\u003c/li\u003e\n\u003cli\u003e逐一排除可能有問題的 Plugin\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e這些方向全部是錯的。\u003c/strong\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"找到根本原因ios-console-log\"\u003e找到根本原因：iOS Console Log\u003c/h2\u003e\n\u003cp\u003e關鍵轉折點是查看 iOS 的 Console log。使用 Xcode 的 \u003cstrong\u003eDevices and Simulators \u0026gt; Open Console\u003c/strong\u003e 或 macOS 的 Console.app 連接 iPhone，重現冷啟動閃退後，看到了這段關鍵訊息：\u003c/p\u003e","title":"Flutter iOS 冷啟動閃退：Debug 模式的隱藏陷阱"},{"content":"問題：功能「先在後不在」 我們替 Strapi 後台新增了 MFA（Multi-Factor Authentication）雙因素認證功能。第一次部署到 STG 時一切正常——登入攔截、TOTP 驗證、QR Code 設定流程都運作良好。\n但幾天後，因為其他功能的修改推了新的 git tag stg-1.24，部署流程順利完成，Jenkins 顯示綠燈——打開 STG 後台，MFA 的登入流程卻完全不見了。\n沒有錯誤訊息、沒有 crash log，功能就這樣無聲無息地消失。\n這比「功能從未出現」更令人困惑：明明之前部署是好的，程式碼也沒有人動過 MFA 相關的部分，為什麼會突然不見？\n調查過程：逐層排除 確認 Git Tag 內容 第一個直覺是——程式碼真的有被包進 tag 嗎？\n# 確認 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 本身沒問題。\n檢查 Pod 內的實際檔案 既然 tag 正確，問題可能出在建置或部署階段。直接進到 Kubernetes Pod 裡看：\n# 進入 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。\n比對 src/index.ts 版本 進一步檢查 Pod 內的 src/index.ts：\nkubectl exec deployment/strapi-stg -- cat src/index.ts 內容是舊版本，完全沒有 MFA 的 import 語句。這表示 Docker build 時用的原始碼根本不是 tag 指向的那個 commit。\n根因分析：Jenkins Workspace 的隱形陷阱 先解釋一下什麼是 Jenkins Workspace。每個 Jenkins job 在執行時，都會在 agent 機器上分配一個工作目錄（通常是 ~/.jenkins/workspace/\u0026lt;job-name\u0026gt;/），用來存放從版本控制系統 checkout 出來的原始碼、建置過程中產生的中間檔案和最終產物。這個目錄就是 workspace。\n關鍵在於：workspace 在建置結束後不會被自動清除。下次同一個 job 再執行時，會直接沿用上次殘留的檔案。這在大多數情況下是好事（可以加速建置），但如果 pipeline 沒有明確 checkout，workspace 裡的程式碼就可能是過時的。\n問題的根因在 Jenkinsfile。以下是原本的 pipeline 結構：\nstages { stage(\u0026#39;Parse Git Tag\u0026#39;) { steps { script { def tagName = env.TAG_NAME // 解析 tag 格式：(dev|stg|prod)-version // ... } } } stage(\u0026#39;Build\u0026#39;) { steps { // docker build ... } } } 注意到了嗎？沒有 checkout scm 階段。\n為什麼會出問題？ Jenkins 的 workspace 是持久化的。當一個 Pipeline 被觸發時，如果沒有明確執行 checkout scm，Jenkins 不會自動切換到 tag 指向的 commit。workspace 裡保留的是上一次建置的檔案。\n這解釋了「先有後無」的時序：\n首次部署 MFA（例如 stg-1.22）：當時 workspace 剛好是正確的版本，MFA 順利部署、功能正常 中間發生了其他建置：可能是不同分支的 tag、或其他 Jenkins job 共用了同一個 workspace，workspace 內容被覆蓋成不包含 MFA 的版本 推送 stg-1.24：Jenkins 觸發建置，但沒有 checkout，workspace 裡是步驟 2 殘留的舊程式碼 Docker COPY . . 複製的是舊程式碼 建置、推送、部署全部成功——只是內容是錯的 sequenceDiagram\rparticipant Dev as Developer\rparticipant Git as Git Repo\rparticipant Jenkins as Jenkins\rparticipant WS as Workspace\rparticipant Docker as Docker Build\rparticipant K8s as Kubernetes\rNote over WS: 初始狀態：空的 workspace\rDev-\u0026gt;\u0026gt;Git: push tag stg-1.22（含 MFA）\rGit-\u0026gt;\u0026gt;Jenkins: 觸發建置\rJenkins-\u0026gt;\u0026gt;WS: 無 checkout，但 workspace 為空\u0026lt;br/\u0026gt;自動 clone 程式碼（含 MFA）\rWS-\u0026gt;\u0026gt;Docker: COPY . .（含 MFA ✅）\rDocker-\u0026gt;\u0026gt;K8s: 部署成功，MFA 正常運作 ✅\rNote over WS: workspace 保留 stg-1.22 的檔案\rDev-\u0026gt;\u0026gt;Git: push tag stg-1.23（其他修改，無 MFA 變更）\rGit-\u0026gt;\u0026gt;Jenkins: 觸發建置\rJenkins-\u0026gt;\u0026gt;WS: 無 checkout\u0026lt;br/\u0026gt;workspace 被覆蓋為 stg-1.23 的內容\rNote over WS: ⚠️ workspace 被汙染\u0026lt;br/\u0026gt;可能缺少 MFA 檔案\rDev-\u0026gt;\u0026gt;Git: push tag stg-1.24（含 MFA）\rGit-\u0026gt;\u0026gt;Jenkins: 觸發建置\rJenkins-\u0026gt;\u0026gt;WS: 無 checkout\u0026lt;br/\u0026gt;沿用 stg-1.23 殘留的舊檔案\rWS-\u0026gt;\u0026gt;Docker: COPY . .（無 MFA ❌）\rDocker-\u0026gt;\u0026gt;K8s: 部署成功，但 MFA 消失 ❌ 為什麼之前沒發現？ 這個 bug 的狡猾之處在於：它不是每次都觸發。如果連續兩次建置之間 workspace 剛好沒被其他 job 覆蓋，程式碼就是對的，一切正常。只有在 workspace 被汙染後，接下來的建置才會出問題。而且如果兩個版本之間差異很小，你根本察覺不到 workspace 用的是舊程式碼。只有像 MFA 這種新增了整個目錄的功能，消失時才會這麼明顯。\n修復：兩步驟解決 立即修復：推送新 Tag git tag stg-1.25 git push origin stg-1.25 由於 Jenkins 的 workspace 在 stg-1.24 建置後已經包含該次（雖然錯誤的）程式碼，再推一次 tag 會讓 workspace 有機會正確同步。但這只是碰運氣，不是根本解決方案。\n永久修復：加入 checkout scm stages { stage(\u0026#39;Checkout\u0026#39;) { steps { checkout scm // 確保 workspace 同步到觸發的 tag commit } } stage(\u0026#39;Parse Git Tag\u0026#39;) { // ... } } checkout scm 會根據觸發這次建置的 SCM 事件（在這裡是 git tag push），將 workspace 切換到對應的 commit。加上這一行，就能保證每次建置用的原始碼都是正確的。\nDocker Cache 不是元兇 調查過程中，我一度懷疑是 Docker BuildKit 的 --cache-from 快取了舊的 COPY . . layer。但這是錯誤的方向。\nDocker BuildKit 的快取機制會對 COPY 指令計算檔案的 checksum。如果原始檔案有變動，快取會自動失效。問題不在 Docker cache，而在餵給 Docker 的檔案本身就是舊的。\n這個誤判提醒了一個重要原則：debug 時要區分「資料來源錯誤」和「處理邏輯錯誤」。Docker 的建置邏輯沒問題，是 Jenkins 餵給它的輸入（workspace 內容）出了問題。\n經驗總結 預設行為的陷阱 很多工程師（包括我）會假設「Jenkins 觸發了 tag 建置，workspace 當然會是那個 tag 的程式碼」。但 Jenkins 的預設行為並非如此。任何「理所當然」的假設都值得驗證。\n除錯方法論 這次除錯的關鍵在於：不要只看建置結果，要進到最終產物裡面看實際內容。\n如果只看 Jenkins console log（全部綠燈）和 Kubernetes rollout status（成功部署），你永遠找不到問題。直接 kubectl exec 進 Pod 看檔案，才能發現原始碼根本不對。\n防範措施 Jenkinsfile 必須有 checkout scm — 永遠不要假設 workspace 是乾淨的 部署後驗證 — 不只看 health check，要驗證關鍵功能是否存在 建置產物比對 — 可以在 CI 中加入步驟，比對 Git commit hash 和 Docker image 內的版本資訊 // 在 Build stage 加入版本標記 sh \u0026#34;echo ${env.GIT_COMMIT} \u0026gt; .git-commit\u0026#34; // 部署後驗證 sh \u0026#34;kubectl exec deployment/\\${DEPLOYMENT} -- cat .git-commit\u0026#34; 這次的經驗再次印證：CI/CD 管線中最危險的 bug，不是建置失敗，而是建置成功但內容錯誤。前者會被立即發現，後者可能潛伏到使用者回報時才被注意到。\n","permalink":"https://peter-notes.com/posts/jenkins-workspace-stale-code-debugging/","summary":"\u003ch2 id=\"問題功能先在後不在\"\u003e問題：功能「先在後不在」\u003c/h2\u003e\n\u003cp\u003e我們替 Strapi 後台新增了 MFA（Multi-Factor Authentication）雙因素認證功能。第一次部署到 STG 時一切正常——登入攔截、TOTP 驗證、QR Code 設定流程都運作良好。\u003c/p\u003e\n\u003cp\u003e但幾天後，因為其他功能的修改推了新的 git tag \u003ccode\u003estg-1.24\u003c/code\u003e，部署流程順利完成，Jenkins 顯示綠燈——打開 STG 後台，MFA 的登入流程卻完全不見了。\u003c/p\u003e\n\u003cp\u003e沒有錯誤訊息、沒有 crash log，功能就這樣無聲無息地消失。\u003c/p\u003e\n\u003cp\u003e這比「功能從未出現」更令人困惑：明明之前部署是好的，程式碼也沒有人動過 MFA 相關的部分，為什麼會突然不見？\u003c/p\u003e\n\u003ch2 id=\"調查過程逐層排除\"\u003e調查過程：逐層排除\u003c/h2\u003e\n\u003ch3 id=\"確認-git-tag-內容\"\u003e確認 Git Tag 內容\u003c/h3\u003e\n\u003cp\u003e第一個直覺是——程式碼真的有被包進 tag 嗎？\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 確認 tag 指向的 commit 包含 MFA 程式碼\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003egit show stg-1.24:src/index.ts \u003cspan class=\"p\"\u003e|\u003c/span\u003e grep -i mfa\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e結果確認 \u003ccode\u003esrc/index.ts\u003c/code\u003e 中有 \u003ccode\u003eimport { registerMfaRoutes } from './mfa/controller'\u003c/code\u003e，\u003ccode\u003esrc/mfa/\u003c/code\u003e 目錄也完整存在。\u003cstrong\u003eGit tag 本身沒問題。\u003c/strong\u003e\u003c/p\u003e\n\u003ch3 id=\"檢查-pod-內的實際檔案\"\u003e檢查 Pod 內的實際檔案\u003c/h3\u003e\n\u003cp\u003e既然 tag 正確，問題可能出在建置或部署階段。直接進到 Kubernetes Pod 裡看：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 進入 STG pod 檢查\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ekubectl \u003cspan class=\"nb\"\u003eexec\u003c/span\u003e -it deployment/strapi-stg -- sh\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 檢查編譯後的檔案\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003els dist/src/mfa/\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 結果：ls: dist/src/mfa/: No such file or directory\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 檢查原始碼\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003els src/mfa/\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 結果：ls: src/mfa/: No such file or directory\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003eMFA 相關檔案完全不存在於 Pod 中。\u003c/strong\u003e 甚至連原始碼都沒有被複製進 Docker image。\u003c/p\u003e","title":"功能部署後憑空消失？一場 Jenkins Workspace 的除錯之旅"},{"content":"症狀：App 開啟 15 分鐘後被強制登出 使用者回報一個詭異的問題：App 正常使用一段時間後，突然被強制登出。重新登入後又好了，但過一陣子又被踢出去。\n時間規律很明確——正好 15 分鐘。這恰好是我們 JWT 的過期時間。\n直覺反應是去查前端的 token refresh 邏輯：interceptor 有沒有正確攔截 401？refresh token 有沒有正確儲存？重試機制有沒有 bug？\n查了一輪，全部正常。\n錯誤的除錯方向 這個問題前前後後花了不少時間，方向一直在前端打轉：\n排查方向 結果 Flutter interceptor 邏輯 正常，有攔截 401 並觸發 refresh Token 儲存機制（SecureStorage） 正常，refresh token 有正確保存 JWT 過期時間設定 確認是 15 分鐘，符合預期 網路連線問題 排除，其他 API 都正常 每個環節看起來都沒問題，但結果就是 refresh 失敗。問題出在哪？\n轉折點：看 HTTP Status Code 直到有人（或者說，終於有人）去看了 /auth/refresh 實際回傳的 HTTP status code：\n# JWT 過期後呼叫 refresh curl -X POST https://api.example.com/api/auth/refresh \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;refreshToken\u0026#34;: \u0026#34;valid-refresh-token-here\u0026#34;}\u0026#39; # 預期：200 OK（新的 JWT） # 實際：403 Forbidden 403，不是 401。\n這是關鍵線索。401 代表「沒有認證」，403 代表「認證了但沒有權限」。回傳 403 意味著 Strapi 的權限系統主動擋下了這個請求——在 refresh handler 有機會執行之前。\n根因：Strapi v5.31+ 的隱性行為變更 追查後發現，Strapi v5.31+ 將 /auth/refresh 和 /auth/logout 從「需要自行註冊的路由」變成了內建路由。\n這個變更本身合理，問題在於：內建路由預設會經過 Strapi 的權限系統驗證 JWT。\n這造成了一個邏輯矛盾：\n使用者的 JWT 過期了 → App 呼叫 /auth/refresh 想換新的 JWT → Strapi 權限系統先檢查 JWT 是否有效 → JWT 已過期，權限系統回傳 403 → refresh handler 根本沒機會執行 → App 收到 403，判定為認證失敗 → 強制登出 用白話說：你拿著過期的門禁卡去保全室想換新卡，但保全在你到達櫃檯之前就因為你的卡過期把你攔在門外了。\n為什麼之前沒這個問題？ 在 v5.31 之前，/auth/refresh 是我們自行註冊的路由，當時的程式碼長這樣：\nplugin.routes[\u0026#34;content-api\u0026#34;].routes.push({ method: \u0026#34;POST\u0026#34;, path: \u0026#34;/auth/refresh\u0026#34;, handler: \u0026#34;auth.refresh\u0026#34;, config: { auth: false, // 繞過權限系統 }, }); auth: false 明確告訴 Strapi：這條路由不需要 JWT 驗證。升級後，內建路由取代了我們的自訂路由，但沒有帶上 auth: false 這個設定。\n修復：補回 auth: false 修復方式是遍歷 plugin 的內建路由，找到 /auth/refresh 和 /auth/logout，補上 auth: false：\n// 取得 content-api 的所有路由 const routes = plugin.routes[\u0026#34;content-api\u0026#34;].routes; for (const route of routes) { if (route.path === \u0026#34;/auth/refresh\u0026#34; || route.path === \u0026#34;/auth/logout\u0026#34;) { route.config = { ...route.config, auth: false, // 繞過權限系統，讓 refresh token 自行驗證 }; } } 為什麼 /auth/logout 也需要？因為登出時 JWT 同樣可能已經過期，如果被權限系統攔下，使用者連登出都做不到（refresh token 不會被撤銷，形成安全隱患）。\n這兩條路由不需要 JWT 驗證的原因是一致的：它們靠 refresh token（而非 JWT）來驗證身份。\n反思：為什麼除錯方向會走偏 回頭看，這個 bug 其實不難修，但找到它卻花了很多時間。核心原因是除錯時犯了一個常見錯誤：\n症狀在前端，就一直在前端找。\n正確的做法應該是：在確認前端邏輯沒問題後，立刻去看後端實際回傳了什麼。如果第一時間就看到 403 而非 401，就能馬上意識到問題出在 Strapi 的權限層，而不是 refresh 邏輯本身。\n版本升級的除錯盲區 另一個讓這個問題難以發現的原因是：它是版本升級帶來的隱性行為變更。\n程式碼沒改、設定沒改、功能沒改——只是 Strapi 升級後，原本由我們註冊的路由變成了內建路由，auth: false 的設定就這樣消失了。這種「升級後行為悄悄改變」的問題，不會出現在 error log 裡，也不會在 CI 跑出紅燈。\n教訓：版本升級後，要特別檢查自訂擴充（extensions）是否被內建功能覆蓋。 尤其是 Strapi 這種 plugin 架構，當框架把原本需要手動註冊的路由改為內建時，你的自訂設定可能會被無聲地吞掉。\n系列文章 本文是「Token 認證機制」系列的延伸：\n會員資料不見？這不是我沒要求，是外包商沒做完整的 OAuth 2.0 — Token 過期問題的發現與方案選擇 Strapi v5 升級後 /auth/refresh 回傳 403 的根因分析（本文）— 實作 refresh token 後踩到的版本升級陷阱 ","permalink":"https://peter-notes.com/posts/strapi-v5-auth-refresh-route-pitfall/","summary":"\u003ch2 id=\"症狀app-開啟-15-分鐘後被強制登出\"\u003e症狀：App 開啟 15 分鐘後被強制登出\u003c/h2\u003e\n\u003cp\u003e使用者回報一個詭異的問題：App 正常使用一段時間後，突然被強制登出。重新登入後又好了，但過一陣子又被踢出去。\u003c/p\u003e\n\u003cp\u003e時間規律很明確——\u003cstrong\u003e正好 15 分鐘\u003c/strong\u003e。這恰好是我們 JWT 的過期時間。\u003c/p\u003e\n\u003cp\u003e直覺反應是去查前端的 token refresh 邏輯：interceptor 有沒有正確攔截 401？refresh token 有沒有正確儲存？重試機制有沒有 bug？\u003c/p\u003e\n\u003cp\u003e查了一輪，全部正常。\u003c/p\u003e\n\u003ch2 id=\"錯誤的除錯方向\"\u003e錯誤的除錯方向\u003c/h2\u003e\n\u003cp\u003e這個問題前前後後花了不少時間，方向一直在前端打轉：\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e排查方向\u003c/th\u003e\n          \u003cth\u003e結果\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eFlutter interceptor 邏輯\u003c/td\u003e\n          \u003ctd\u003e正常，有攔截 401 並觸發 refresh\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eToken 儲存機制（SecureStorage）\u003c/td\u003e\n          \u003ctd\u003e正常，refresh token 有正確保存\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eJWT 過期時間設定\u003c/td\u003e\n          \u003ctd\u003e確認是 15 分鐘，符合預期\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e網路連線問題\u003c/td\u003e\n          \u003ctd\u003e排除，其他 API 都正常\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e每個環節看起來都沒問題，但結果就是 refresh 失敗。問題出在哪？\u003c/p\u003e\n\u003ch2 id=\"轉折點看-http-status-code\"\u003e轉折點：看 HTTP Status Code\u003c/h2\u003e\n\u003cp\u003e直到有人（或者說，終於有人）去看了 \u003ccode\u003e/auth/refresh\u003c/code\u003e 實際回傳的 HTTP status code：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# JWT 過期後呼叫 refresh\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecurl -X POST https://api.example.com/api/auth/refresh \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  -H \u003cspan class=\"s2\"\u003e\u0026#34;Content-Type: application/json\u0026#34;\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  -d \u003cspan class=\"s1\"\u003e\u0026#39;{\u0026#34;refreshToken\u0026#34;: \u0026#34;valid-refresh-token-here\u0026#34;}\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 預期：200 OK（新的 JWT）\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 實際：403 Forbidden\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e403，不是 401。\u003c/strong\u003e\u003c/p\u003e","title":"升級 Strapi v5.31+ 後 JWT 過期就被登出？問題藏在你沒注意到的內建路由裡"},{"content":"問題發生 在將 Production 環境複製到 Staging 環境後，發現 Strapi CMS 無法上傳圖片到媒體庫，畫面只顯示 Internal Server Error。\nStrapi 是一個開源的 Headless CMS（無頭內容管理系統），可以讓開發者快速建立 API，並提供管理後台來管理內容。在這個專案中，我們使用 Strapi 搭配 AWS S3 來儲存上傳的圖片和檔案。\n追查過程 第一步：查看 Kubernetes Logs 由於 Strapi 部署在 EKS（Elastic Kubernetes Service，AWS 的託管 Kubernetes 服務）上，我透過 kubectl 指令查看 Pod 的日誌：\nkubectl logs -f deployment/strapi-stg --tail=100 kubectl 是 Kubernetes 的命令列工具，用來與 Kubernetes 叢集互動。logs 指令可以查看容器的輸出日誌。\n第二步：找到錯誤訊息 在日誌中發現關鍵錯誤：\nerror: The bucket does not allow ACLs AccessControlListNotSupported: The bucket does not allow ACLs 這個錯誤訊息指出 S3 Bucket（AWS 的物件儲存服務中的儲存桶）不允許使用 ACL。\n什麼是 ACL？ ACL = Access Control List（存取控制清單）\nACL 是 S3 的一種權限管理機制，用來決定誰可以讀取或寫入特定的檔案。當 Strapi 上傳圖片到 S3 時，預設會設定：\nACL: \u0026#34;public-read\u0026#34; 這表示「任何人都可以讀取這張圖片」，這樣前端網頁才能透過 URL 直接顯示圖片。\n為什麼會失敗？ AWS 在 2023 年 4 月的重大變更 AWS 在 2023 年 4 月之後，新建立的 S3 Bucket 預設禁用 ACL。這是一個安全性的改進，AWS 建議改用 Bucket Policy 來統一管理權限。\n新 Bucket 的預設設定：\nObject Ownership = Bucket owner enforced（禁用 ACL） Block all public access = On（封鎖所有公開存取） 為什麼 Production 正常，Staging 失敗？ 環境 Bucket 建立時間 ACL 設定 Production 2025/10/3 ✅ 有手動啟用 ACL Staging 2026/1/6 ❌ 使用預設值（禁用） 問題不在於 AWS 的預設值改變，而是 建立 Staging Bucket 時忘記調整 ACL 設定，而 Production 當初有手動啟用。\n解決方案 步驟一：啟用 Object Ownership 的 ACL 進入 AWS S3 Console，選擇目標 Bucket 點擊 Permissions 分頁 找到 Object Ownership 區塊，點擊 Edit 選擇 ACLs enabled，然後選擇 Bucket owner preferred 勾選 I acknowledge that ACLs will be restored 點擊 Save changes Object Ownership 決定了誰擁有上傳到 Bucket 的物件，以及是否可以使用 ACL：\nBucket owner enforced：禁用 ACL，所有物件都屬於 Bucket 擁有者 Bucket owner preferred：啟用 ACL，但 Bucket 擁有者會取得完整控制權 步驟二：調整 Block Public Access 設定 在同一個 Permissions 分頁 找到 Block public access 區塊，點擊 Edit 取消勾選前兩個 ACL 相關選項： ❌ Block public access to buckets and objects granted through new access control lists (ACLs) ❌ Block public access to buckets and objects granted through any access control lists (ACLs) 保留下面兩個 Bucket Policy 相關的勾選 點擊 Save changes 這樣 Strapi 上傳時設定的 ACL: \u0026quot;public-read\u0026quot; 才會生效，圖片才能被前端讀取。\n預防措施：使用 IaC 管理雲端資源 這次問題的根本原因是手動建立 Bucket 時遺漏設定。如果使用 IaC（Infrastructure as Code，基礎設施即程式碼） 來管理，就能確保每次建立的資源設定一致。\n什麼是 IaC？ IaC = Infrastructure as Code（基礎設施即程式碼）\n傳統上，我們在 AWS Console 網頁上點選按鈕來建立 S3 Bucket、EC2 主機等雲端資源。IaC 則是把這些操作寫成程式碼檔案，執行程式碼就能自動建立資源。\n這就像是：\n手動方式：每次做菜都憑記憶，可能漏放調味料 IaC 方式：照著食譜做，每次結果都一樣 常見的 IaC 工具：\nTerraform：最流行的跨平台 IaC 工具，可管理 AWS、GCP、Azure 等多種雲端 AWS CloudFormation：AWS 原生的 IaC 服務，只能管理 AWS 資源 Pulumi：支援 TypeScript、Python 等程式語言撰寫 IaC 什麼是 Terraform？ Terraform 是由 HashiCorp 公司開發的開源 IaC 工具。它使用自己的設定語言 HCL（HashiCorp Configuration Language） 來描述雲端資源。\nTerraform 的運作流程：\nWrite：撰寫 .tf 設定檔，描述你想要的資源 Plan：執行 terraform plan，預覽將會建立或修改的資源 Apply：執行 terraform apply，實際建立資源 Terraform 範例 以下是用 Terraform 建立 S3 Bucket 並啟用 ACL 的範例：\n# 建立 S3 Bucket resource \u0026#34;aws_s3_bucket\u0026#34; \u0026#34;media_bucket\u0026#34; { bucket = \u0026#34;my-app-media-stg\u0026#34; } # 設定 Object Ownership 為 BucketOwnerPreferred（啟用 ACL） resource \u0026#34;aws_s3_bucket_ownership_controls\u0026#34; \u0026#34;media_bucket\u0026#34; { bucket = aws_s3_bucket.media_bucket.id rule { object_ownership = \u0026#34;BucketOwnerPreferred\u0026#34; } } # 設定 Public Access Block（允許 ACL 公開存取） resource \u0026#34;aws_s3_bucket_public_access_block\u0026#34; \u0026#34;media_bucket\u0026#34; { bucket = aws_s3_bucket.media_bucket.id block_public_acls = false # 允許設定 public ACL ignore_public_acls = false # 不忽略 public ACL block_public_policy = true # 封鎖 public bucket policy restrict_public_buckets = true # 限制 public bucket } 使用 IaC 的好處：\n手動建立 使用 IaC 每次都要記得點哪些選項 程式碼定義好，執行就一致 容易遺漏設定 不會漏，設定都在程式碼裡 無法追蹤誰改了什麼 可以用 Git 版本控制 各環境可能設定不同 同一份程式碼，環境一致 結論 這次問題的關鍵學習：\nAWS S3 在 2023 年 4 月後預設禁用 ACL，這是安全性改進，但可能導致舊有程式碼上傳失敗 錯誤訊息 The bucket does not allow ACLs 表示需要啟用 Object Ownership 的 ACL 設定 手動建立雲端資源容易遺漏設定，建議使用 IaC 工具來確保一致性 下次遇到類似的 S3 上傳問題，記得先檢查 Bucket 的 Object Ownership 和 Block Public Access 設定。\n","permalink":"https://peter-notes.com/posts/aws-s3-acl-upload-error/","summary":"\u003ch2 id=\"問題發生\"\u003e問題發生\u003c/h2\u003e\n\u003cp\u003e在將 Production 環境複製到 Staging 環境後，發現 Strapi CMS 無法上傳圖片到媒體庫，畫面只顯示 \u003ccode\u003eInternal Server Error\u003c/code\u003e。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eStrapi\u003c/strong\u003e 是一個開源的 Headless CMS（無頭內容管理系統），可以讓開發者快速建立 API，並提供管理後台來管理內容。在這個專案中，我們使用 Strapi 搭配 AWS S3 來儲存上傳的圖片和檔案。\u003c/p\u003e\n\u003ch2 id=\"追查過程\"\u003e追查過程\u003c/h2\u003e\n\u003ch3 id=\"第一步查看-kubernetes-logs\"\u003e第一步：查看 Kubernetes Logs\u003c/h3\u003e\n\u003cp\u003e由於 Strapi 部署在 \u003cstrong\u003eEKS\u003c/strong\u003e（Elastic Kubernetes Service，AWS 的託管 Kubernetes 服務）上，我透過 \u003ccode\u003ekubectl\u003c/code\u003e 指令查看 Pod 的日誌：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ekubectl logs -f deployment/strapi-stg --tail\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"m\"\u003e100\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003ekubectl\u003c/strong\u003e 是 Kubernetes 的命令列工具，用來與 Kubernetes 叢集互動。\u003ccode\u003elogs\u003c/code\u003e 指令可以查看容器的輸出日誌。\u003c/p\u003e\n\u003ch3 id=\"第二步找到錯誤訊息\"\u003e第二步：找到錯誤訊息\u003c/h3\u003e\n\u003cp\u003e在日誌中發現關鍵錯誤：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eerror: The bucket does not allow ACLs\nAccessControlListNotSupported: The bucket does not allow ACLs\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e這個錯誤訊息指出 \u003cstrong\u003eS3 Bucket\u003c/strong\u003e（AWS 的物件儲存服務中的儲存桶）不允許使用 \u003cstrong\u003eACL\u003c/strong\u003e。\u003c/p\u003e","title":"AWS S3 Upload Failed: The Bucket Does Not Allow ACLs"},{"content":"延續上一篇的問題 在上一篇文章中，我們遇到了一個問題：Link Table 資料遺失導致使用者沒有角色，所有 API 都回傳 401。\n文章最後我拋出了一個問題：\n如果你的系統把角色資訊 cache 在 Redis、JWT claim、或 BFF 層，當 Link Table 資料不正確時，系統應該：\n名詞解釋：\nRedis：一種高速的記憶體資料庫（In-Memory Database），常用於快取（Cache）熱門資料，避免每次都查詢主資料庫 JWT claim：JWT Token 內的資料欄位。例如把使用者角色直接寫在 Token 裡：{ \u0026quot;sub\u0026quot;: \u0026quot;user_123\u0026quot;, \u0026quot;role\u0026quot;: \u0026quot;admin\u0026quot; }，這樣就不用每次都查 DB BFF（Backend For Frontend）：一種架構模式，在前端和後端 API 之間多一層「專為前端服務的後端」，常會在這層做權限快取 立即全站拒絕？ 繼續相信 cache？ 還是進入 degraded mode？ 我的選擇是：進入 Degraded Mode。\n這篇文章會解釋為什麼，以及如何實作。\n一句話定義 Degraded Mode = 系統已知自己「部分不可信」，主動降級功能以維持安全與可用性。\n不是壞了、不是裝沒事，而是：\n我知道哪裡壞 我知道哪些功能不能給 我知道要保住什麼 為什麼不選另外兩個方案？ ❌ 方案 A：立即全站拒絕 所有 API → 401/503 → 業務全掛 問題：\n使用者完全無法使用系統 對業務衝擊太大 「寧可錯殺一百」的策略在商業系統中代價過高 適用場景： 金融交易、醫療處方等「錯了比沒有更糟」的場景\n❌ 方案 B：繼續相信 Cache JWT claim 說你是 admin → 就讓你當 admin 問題：\n如果 DB 資料才是真相，cache 可能已經過時 權限外洩風險 「裝沒事」是最危險的策略 適用場景： 幾乎沒有。這是最糟的選擇。\n✅ 方案 C：進入 Degraded Mode JWT 有效 → 信 identity Role 不可信 → 降級功能 寫入操作 → 靜默拒絕 這是平衡安全性與可用性的最佳解。\nDegraded Mode 的三個核心原則 原則 1：Fail Closed（權限要保守） 不確定能不能給 → 就不要給\n這是安全系統的基本原則。當授權資料不可信時，預設行為應該是「拒絕」而非「允許」。\n原則 2：功能降級 ≠ 系統死亡 核心服務要活，但敏感操作要關\n使用者還是可以登入、可以看資料，只是不能做寫入操作。這比全站掛掉好得多。\n原則 3：可觀測、可恢復 系統要知道「我現在在 degraded」\n這不是 runtime exception，是一個明確的系統狀態。要能監控、要能告警、要能手動恢復。\n狀態機設計 觸發 AUTHZ_DEGRADED 的條件：\n// 啟動時自檢 async function checkRoleIntegrity(): Promise\u0026lt;boolean\u0026gt; { // 條件 1：Link Table 是否有資料 const linkCount = await db(\u0026#39;users_roles_lnk\u0026#39;).count(); if (linkCount === 0) return false; // 條件 2：使用者是否都有角色 const orphanUsers = await db.raw(` SELECT COUNT(*) FROM users u LEFT JOIN users_roles_lnk lnk ON u.id = lnk.user_id WHERE lnk.role_id IS NULL `); if (orphanUsers.count \u0026gt; 100) return false; // 容忍少量異常 return true; } 功能矩陣：什麼能做、什麼不能做 功能 NORMAL DEGRADED 說明 登入 ✅ ✅ 認證層正常 讀取個人資料 ✅ ✅ 唯讀安全 讀取公開內容 ✅ ✅ 無權限需求 建立訂單 ✅ ❌ 寫入操作 修改個人設定 ✅ ❌ 寫入操作 管理後台 ✅ ❌ 高權限操作 刪除資料 ✅ ❌ 破壞性操作 原則：唯讀 + 最小暴露\n實作：Middleware 層的守門員 // 系統狀態管理 enum SystemState { NORMAL = \u0026#39;NORMAL\u0026#39;, AUTHZ_DEGRADED = \u0026#39;AUTHZ_DEGRADED\u0026#39;, } class SystemStateManager { private state: SystemState = SystemState.NORMAL; setDegraded() { this.state = SystemState.AUTHZ_DEGRADED; logger.warn(\u0026#39;[SYSTEM] Entering AUTHZ_DEGRADED mode\u0026#39;); this.notifyOps(); // 發送告警 } isDegraded(): boolean { return this.state === SystemState.AUTHZ_DEGRADED; } private notifyOps() { // Slack、PagerDuty、Sentry... } } const systemState = new SystemStateManager(); // Middleware：根據操作類型決定是否放行 function degradedGuard(requiredLevel: \u0026#39;read\u0026#39; | \u0026#39;write\u0026#39;) { return async (ctx, next) =\u0026gt; { if (systemState.isDegraded() \u0026amp;\u0026amp; requiredLevel === \u0026#39;write\u0026#39;) { // 靜默拒絕，不回傳錯誤 ctx.status = 204; // No Content return; } return next(); }; } // 使用方式 router.get(\u0026#39;/api/me\u0026#39;, degradedGuard(\u0026#39;read\u0026#39;), getProfile); router.post(\u0026#39;/api/orders\u0026#39;, degradedGuard(\u0026#39;write\u0026#39;), createOrder); router.delete(\u0026#39;/api/users/:id\u0026#39;, degradedGuard(\u0026#39;write\u0026#39;), deleteUser); 關鍵設計：靜默拒絕\n// ❌ 不要這樣 ctx.throw(503, \u0026#39;System in degraded mode\u0026#39;); // ✅ 這樣做 ctx.status = 204; // 操作被吃掉，但不報錯 為什麼？因為 Degraded State 是系統內部狀態，不是產品狀態。\n為什麼選擇「靜默」？ 使用者不需要知道系統哪裡壞了 ❌ 暴露 degraded 給前端： - 前端要寫 if (degraded) { ... } - UX 出現模糊的警告訊息 - PM 問：什麼時候會好？ - 系統狀態污染產品語意 ✅ 靜默處理： - 按鈕自然不能按 - 表單自然不能送 - 沒有錯誤碼、沒有恐慌訊息 - 系統像「變慢、變保守」，但沒壞 這叫 Graceful Degradation（優雅降級）。\n責任分界 系統該負責的事 產品不該知道的事 判斷是否 degraded 哪張表壞了 決定哪些能力可用 RBAC 是否完整 阻止高風險操作 Link Table 有沒有資料 發送內部告警 系統內部狀態 產品只關心「能力」，不關心「原因」。\nJWT 怎麼處理？答案是「半信」 在 Degraded Mode 下：\n// ✅ 信 identity（使用者是誰） const userId = jwt.sub; // 這個可以信 // ❌ 不信 role/permission（使用者能做什麼） const userRole = jwt.role; // 這個不能信 JWT 在 degraded mode 下只剩下：\n{ \u0026#34;sub\u0026#34;: \u0026#34;user_12345\u0026#34;, \u0026#34;iat\u0026#34;: 1705123456 } Role 和 permission 一律從「不信任」的角度處理。\n監控：沒有觀測就等於沒做 至少需要三個 signal：\n// 1. 系統狀態指標 metrics.gauge(\u0026#39;system_state\u0026#39;, { state: systemState.isDegraded() ? \u0026#39;AUTHZ_DEGRADED\u0026#39; : \u0026#39;NORMAL\u0026#39; }); // 2. 被擋下的寫入請求數量 metrics.counter(\u0026#39;degraded_write_blocked\u0026#39;, { endpoint: ctx.path }); // 3. Degraded 持續時間 metrics.gauge(\u0026#39;degraded_duration_seconds\u0026#39;, { since: degradedStartTime }); 這些只會出現在：\nGrafana Dashboard Slack 告警 Sentry Tag 使用者永遠看不到。\n資料層的保護 在 Degraded Mode 下，資料層要做到「什麼都不做」：\nasync function createOrder(data) { // 最後一道防線 if (systemState.isDegraded()) { logger.info(\u0026#39;[DEGRADED] Order creation skipped\u0026#39;, { data }); return null; // 靜默返回 } // 正常邏輯 return await db(\u0026#39;orders\u0026#39;).insert(data); } Degraded Mode 下禁止：\n❌ 寫入 DB ❌ 更新 Cache ❌ 發送 Event ❌ 進入 Queue 寧願什麼都不做，也不要做錯。\n這是一種產品哲學 我選擇的是：\n「系統不穩定，不應該成為使用者的心理負擔。」\n這在醫療系統、金融系統、長期使用型 App 中，是非常重要的價值觀。\n使用者打開 App，看到「系統維護中」會焦慮。但如果他只是發現「今天好像不能下單」，他可能只會想「等一下再試」。\n差異在於：我們沒有把系統問題轉嫁給使用者。\n結論 當授權資料不可信時：\n選項 安全性 可用性 使用者體驗 全站拒絕 ⭐⭐⭐ ❌ 😱 恐慌 相信 Cache ❌ ⭐⭐⭐ 😊 但有風險 Degraded Mode ⭐⭐ ⭐⭐ 😐 正常但受限 Degraded Mode 是平衡的藝術。\n最後一句話：\n當授權資料不可信時，最危險的不是服務中斷，而是系統假裝一切正常。\n系列文章 資料庫同步的隱藏陷阱：Link Table 的重要性 當授權資料不可信時，我選擇讓系統安靜地退後一步（本文） ","permalink":"https://peter-notes.com/posts/authorization-degraded-mode-design/","summary":"\u003ch2 id=\"延續上一篇的問題\"\u003e延續上一篇的問題\u003c/h2\u003e\n\u003cp\u003e在\u003ca href=\"/posts/database-sync-link-table-pitfall/\"\u003e上一篇文章\u003c/a\u003e中，我們遇到了一個問題：\u003cstrong\u003eLink Table 資料遺失導致使用者沒有角色，所有 API 都回傳 401\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e文章最後我拋出了一個問題：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e如果你的系統把角色資訊 cache 在 Redis、JWT claim、或 BFF 層，當 Link Table 資料不正確時，系統應該：\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003e名詞解釋：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eRedis\u003c/strong\u003e：一種高速的記憶體資料庫（In-Memory Database），常用於快取（Cache）熱門資料，避免每次都查詢主資料庫\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eJWT claim\u003c/strong\u003e：JWT Token 內的資料欄位。例如把使用者角色直接寫在 Token 裡：\u003ccode\u003e{ \u0026quot;sub\u0026quot;: \u0026quot;user_123\u0026quot;, \u0026quot;role\u0026quot;: \u0026quot;admin\u0026quot; }\u003c/code\u003e，這樣就不用每次都查 DB\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eBFF（Backend For Frontend）\u003c/strong\u003e：一種架構模式，在前端和後端 API 之間多一層「專為前端服務的後端」，常會在這層做權限快取\u003c/li\u003e\n\u003c/ul\u003e\n\u003cblockquote\u003e\n\u003cul\u003e\n\u003cli\u003e立即全站拒絕？\u003c/li\u003e\n\u003cli\u003e繼續相信 cache？\u003c/li\u003e\n\u003cli\u003e還是進入 degraded mode？\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e我的選擇是：\u003cstrong\u003e進入 Degraded Mode\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e這篇文章會解釋為什麼，以及如何實作。\u003c/p\u003e\n\u003ch2 id=\"一句話定義\"\u003e一句話定義\u003c/h2\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003eDegraded Mode = 系統已知自己「部分不可信」，主動降級功能以維持安全與可用性。\u003c/strong\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e不是壞了、不是裝沒事，而是：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e我知道哪裡壞\u003c/li\u003e\n\u003cli\u003e我知道哪些功能不能給\u003c/li\u003e\n\u003cli\u003e我知道要保住什麼\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"為什麼不選另外兩個方案\"\u003e為什麼不選另外兩個方案？\u003c/h2\u003e\n\u003ch3 id=\"-方案-a立即全站拒絕\"\u003e❌ 方案 A：立即全站拒絕\u003c/h3\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e所有 API → 401/503 → 業務全掛\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e\u003cstrong\u003e問題：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e使用者完全無法使用系統\u003c/li\u003e\n\u003cli\u003e對業務衝擊太大\u003c/li\u003e\n\u003cli\u003e「寧可錯殺一百」的策略在商業系統中代價過高\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e適用場景：\u003c/strong\u003e 金融交易、醫療處方等「錯了比沒有更糟」的場景\u003c/p\u003e","title":"當授權資料不可信時，我選擇讓系統安靜地退後一步"},{"content":"問題現象：登入成功卻被拒於門外 最近在 Staging 環境遇到一個詭異的問題：使用者登入成功，拿到了有效的 JWT Token，但存取任何需要認證的 API 都回傳 401 Unauthorized。\n# 登入成功，拿到 token POST /api/auth/local → 200 OK { \u0026#34;jwt\u0026#34;: \u0026#34;eyJhbGc...xxxxx...your-jwt-token\u0026#34;, \u0026#34;user\u0026#34;: { \u0026#34;id\u0026#34;: 1001, \u0026#34;email\u0026#34;: \u0026#34;user@example.com\u0026#34; } } # 但存取個人資料失敗 GET /api/users/me → 401 Unauthorized Token 驗證通過、使用者存在、帳號未被封鎖。問題到底在哪？\n根本原因：遺失的 Link Table 經過一番追查，發現問題出在資料庫同步時漏掉了關聯表（Link Table）。\n什麼是 Link Table？ 在關聯式資料庫中，多對多關係需要透過中間表來建立。這個中間表就是 Link Table（也稱為 Junction Table、Join Table、或 Pivot Table）。\n使用者與角色的關係：\n一個使用者可以有多個角色（User → Roles） 一個角色可以分配給多個使用者（Role → Users） 這是典型的多對多關係 各種 ORM 的 Link Table 命名 不同框架的 Link Table 命名慣例不同，但概念完全相同：\nORM/Framework Link Table 範例 備註 Django user_groups, user_permissions 使用 _ 連接 Laravel role_user, permission_role 字母順序排列 TypeORM user_roles_role 較長的命名 Prisma _UserToRole 以 _ 開頭 Sequelize UserRoles 駝峰命名 問題的本質：資料不完整 當我們同步資料庫時，通常會注意主要的資料表：\n-- 我們記得同步這些表 pg_dump -t users -t roles -t products -t orders ... 但很容易忘記那些「看起來不重要」的 Link Table：\n-- 卻忘了這些 pg_dump -t users_roles_lnk -t products_categories_lnk ... 結果：\n使用者資料存在 ✅ 角色定義存在 ✅ 但使用者沒有被分配任何角色 ❌ 為什麼這會導致 401？ 讓我們看看認證流程：\n關鍵差異：\n操作 需要角色？ 結果 登入 (POST /auth/local) ❌ 不需要 成功 取得個人資料 (GET /users/me) ✅ 需要 Authenticated 角色 失敗 任何 CRUD 操作 ✅ 需要角色權限 失敗 大多數系統的權限設計是：有角色才有權限，沒角色等於訪客。\n401 vs 403：這其實是 Authorization 問題 這裡有個常見的誤解：看到 401 Unauthorized，直覺會去查 JWT、Token、密鑰設定。但這個案例其實是 Authorization（授權） 問題，不是 Authentication（認證） 問題。\n理想的 HTTP 狀態碼設計：\n情況 正確狀態碼 意義 沒有 Token 401 請先登入 Token 無效/過期 401 Token 有問題 Token 有效但沒權限 403 已登入但權限不足 但很多系統（包括這次遇到的）把「沒權限」也回傳 401。為什麼？\n因為系統把「沒有角色」視為「等同未登入」：\n有 Token + 有角色 → 已認證使用者 → 檢查權限 有 Token + 沒角色 → 視為訪客 → 401（你不是「已認證使用者」） 這是一種設計選擇：系統認為「Authenticated」角色是「已登入」的證明，沒有這個角色就不算完成認證流程。從這個角度看，回傳 401 在邏輯上是正確的 — 但對除錯者來說，這個設計隱藏了真正的問題。\n如果系統能區分「Token 無效」和「Token 有效但無角色」 (403)，回傳不同的錯誤訊息，除錯會容易很多。\n為什麼 ORM Migration 救不了你？ 你可能會問：ORM 不是會幫我處理 schema 嗎？\nORM 關注的是 Schema Shape，不是 Data Integrity：\n✅ ORM 會檢查：表存在嗎？欄位對嗎？型別正確嗎？ ❌ ORM 不會檢查：Link Table 有資料嗎？關係正確嗎？ 對 ORM 來說，一張空的 users_roles_lnk 是完全合法的狀態 — 它只是代表「目前沒有使用者被分配角色」。ORM 無法分辨這是「設計如此」還是「同步遺漏」。\n這就是為什麼整合測試（Integration Test）才會發現問題，而 migration 和 schema validation 都通過。\n如何診斷這個問題 步驟 1：確認使用者存在 SELECT id, email, confirmed, blocked FROM users WHERE email = \u0026#39;user@example.com\u0026#39;; -- 結果：使用者存在且狀態正常 -- | id | email | confirmed | blocked | -- | 1001 | user@example.com | true | false | 步驟 2：檢查角色分配 -- 查詢使用者的角色 SELECT u.id, u.email, r.name as role_name FROM users u LEFT JOIN users_roles_lnk lnk ON u.id = lnk.user_id LEFT JOIN roles r ON lnk.role_id = r.id WHERE u.email = \u0026#39;user@example.com\u0026#39;; -- 如果 role_name 是 NULL，問題就在這裡！ 步驟 3：統計受影響的使用者 -- 找出所有沒有角色的使用者 SELECT COUNT(*) as users_without_roles FROM users u LEFT JOIN users_roles_lnk lnk ON u.id = lnk.user_id WHERE lnk.role_id IS NULL; -- 我們的案例：3,371 個使用者沒有角色！ 解決方案 修復：批次分配角色 確認問題後，修復其實很簡單：\n-- 找出 Authenticated 角色的 ID SELECT id FROM roles WHERE type = \u0026#39;authenticated\u0026#39;; -- 假設是 1 -- 為所有沒有角色的使用者分配 Authenticated 角色 INSERT INTO users_roles_lnk (user_id, role_id, user_ord) SELECT u.id, 1, 1 FROM users u LEFT JOIN users_roles_lnk lnk ON u.id = lnk.user_id WHERE lnk.role_id IS NULL ON CONFLICT DO NOTHING; 預防：Bootstrap 自動檢查 在應用程式啟動時自動檢測並修復：\n// 應用程式啟動時執行 async function ensureUserRoles() { // 找出沒有角色的使用者 const usersWithoutRoles = await db.raw(` SELECT u.id FROM users u LEFT JOIN users_roles_lnk lnk ON u.id = lnk.user_id WHERE lnk.role_id IS NULL `); if (usersWithoutRoles.length \u0026gt; 0) { console.warn(`Found ${usersWithoutRoles.length} users without roles`); // 取得預設角色 ID const defaultRole = await db(\u0026#39;roles\u0026#39;) .where({ type: \u0026#39;authenticated\u0026#39; }) .first(); // 批次分配角色 const values = usersWithoutRoles .map(u =\u0026gt; `(${u.id}, ${defaultRole.id}, 1)`) .join(\u0026#39;, \u0026#39;); await db.raw(` INSERT INTO users_roles_lnk (user_id, role_id, user_ord) VALUES ${values} ON CONFLICT DO NOTHING `); console.info(`Assigned default role to ${usersWithoutRoles.length} users`); } } 資料庫同步 Checklist 未來進行資料庫同步時，請確認：\n## 同步前檢查 - [ ] 列出所有需要同步的表（包含 Link Table） - [ ] 確認外鍵關係完整 - [ ] 備份目標環境資料 ## 同步後驗證 - [ ] 檢查 users 表筆數 - [ ] 檢查 Link Table 筆數（應該 \u0026gt; 0） - [ ] 執行整合測試（登入 + API 存取） - [ ] 抽查使用者權限是否正常 如果沒發現這個問題會怎樣？ 讓我們模擬一下「這個問題上到 Production」的災難情境：\n📅 Day 1 - 資料庫同步完成，看起來一切正常 - 新使用者註冊正常（會自動分配角色） - QA 測試用的是新建帳號 ✅ 📅 Day 3 - 舊使用者開始抱怨：「我登入成功但什麼都看不到」 - 客服以為是個案，請使用者清快取、重新登入 - 問題回報增加，但登入功能確實正常 🤔 📅 Day 5 - 發現 3,000+ 使用者都有問題 - 工程師開始查 JWT、Token、密鑰設定 - 一直找不到問題（因為方向錯了） 📅 Day 7 - 終於有人想到查資料庫權限 - 發現 Link Table 是空的 - 緊急修復，但商譽已受損 💸 最可怕的是： 這種問題很難被自動化測試發現，因為：\n單元測試不會跨表查詢 整合測試通常用新建的測試資料 舊使用者的狀態沒人測 Link Table：資料庫的暗物質 我喜歡把 Link Table 比喻為「資料庫的暗物質（Dark Matter）」：\n特性 業務資料表 Link Table 有後台管理介面 ✅ ❌ 有 API 直接操作 ✅ ❌ 會出現在 Log ✅ 很少 被人記得存在 ✅ ❌ 影響系統運作 ✅ ✅✅✅ Link Table 的應用遠不只 User-Role：\nRBAC（Role-Based Access Control）權限系統：users_roles_lnk, roles_permissions_lnk 標籤系統：posts_tags_lnk, products_categories_lnk Feature Flag：users_features_lnk A/B Test：users_experiments_lnk 這些表沒有自己的 UI，沒有獨立的 CRUD API，但它們決定了「誰可以做什麼」、「誰看到什麼」。\n結論 這個問題表面上是「401 認證失敗」，實際上是資料庫同步不完整。Link Table 雖然「看起來不重要」，卻是權限系統的關鍵。\n核心教訓：\nLink Table 是隱形的關鍵 — 它們沒有業務邏輯，但定義了實體間的關係 同步時要思考「關係」而非「表」 — 不只是同步資料，更要同步資料間的關聯 防禦性程式設計 — 在應用層加入自動檢測和修復機制 這次問題在 Staging 環境被發現，讓我們有機會在上線前修正。下次遇到「登入成功但 API 失敗」的詭異問題時，記得檢查使用者是否真的有被分配角色。那個看起來無害的 Link Table，可能就是問題的根源。\n延伸思考 如果你的系統把角色資訊 cache 在 Redis、JWT claim、或 BFF 層，當 Link Table 資料不正確時：\n系統應該立即全站拒絕？ 還是繼續相信 cache？ 或是進入 degraded mode？ 這已經不只是資料庫問題，而是系統可信度設計的議題了。\n👉 我在下一篇文章分享了我的選擇：當授權資料不可信時，我選擇讓系統安靜地退後一步\n系列文章 資料庫同步的隱藏陷阱：Link Table 的重要性（本文） 當授權資料不可信時，我選擇讓系統安靜地退後一步 ","permalink":"https://peter-notes.com/posts/database-sync-link-table-pitfall/","summary":"\u003ch2 id=\"問題現象登入成功卻被拒於門外\"\u003e問題現象：登入成功卻被拒於門外\u003c/h2\u003e\n\u003cp\u003e最近在 \u003cstrong\u003eStaging 環境\u003c/strong\u003e遇到一個詭異的問題：使用者登入成功，拿到了有效的 JWT Token，但存取任何需要認證的 API 都回傳 \u003cstrong\u003e401 Unauthorized\u003c/strong\u003e。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 登入成功，拿到 token\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ePOST /api/auth/local → \u003cspan class=\"m\"\u003e200\u003c/span\u003e OK\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"o\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;jwt\u0026#34;\u003c/span\u003e: \u003cspan class=\"s2\"\u003e\u0026#34;eyJhbGc...xxxxx...your-jwt-token\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;user\u0026#34;\u003c/span\u003e: \u003cspan class=\"o\"\u003e{\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;id\u0026#34;\u003c/span\u003e: 1001, \u003cspan class=\"s2\"\u003e\u0026#34;email\u0026#34;\u003c/span\u003e: \u003cspan class=\"s2\"\u003e\u0026#34;user@example.com\u0026#34;\u003c/span\u003e \u003cspan class=\"o\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"o\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 但存取個人資料失敗\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eGET /api/users/me → \u003cspan class=\"m\"\u003e401\u003c/span\u003e Unauthorized\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eToken 驗證通過、使用者存在、帳號未被封鎖。問題到底在哪？\u003c/p\u003e\n\u003ch2 id=\"根本原因遺失的-link-table\"\u003e根本原因：遺失的 Link Table\u003c/h2\u003e\n\u003cp\u003e經過一番追查，發現問題出在\u003cstrong\u003e資料庫同步時漏掉了關聯表（Link Table）\u003c/strong\u003e。\u003c/p\u003e\n\u003ch3 id=\"什麼是-link-table\"\u003e什麼是 Link Table？\u003c/h3\u003e\n\u003cp\u003e在關聯式資料庫中，\u003cstrong\u003e多對多關係\u003c/strong\u003e需要透過中間表來建立。這個中間表就是 Link Table（也稱為 Junction Table、Join Table、或 Pivot Table）。\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/2026-01-20-database-sync-link-table-pitfall-diagram-e979ee1f.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e\u003cstrong\u003e使用者與角色的關係：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e一個使用者可以有多個角色（User → Roles）\u003c/li\u003e\n\u003cli\u003e一個角色可以分配給多個使用者（Role → Users）\u003c/li\u003e\n\u003cli\u003e這是典型的\u003cstrong\u003e多對多關係\u003c/strong\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"各種-orm-的-link-table-命名\"\u003e各種 ORM 的 Link Table 命名\u003c/h3\u003e\n\u003cp\u003e不同框架的 Link Table 命名慣例不同，但概念完全相同：\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eORM/Framework\u003c/th\u003e\n          \u003cth\u003eLink Table 範例\u003c/th\u003e\n          \u003cth\u003e備註\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eDjango\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003euser_groups\u003c/code\u003e, \u003ccode\u003euser_permissions\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e使用 \u003ccode\u003e_\u003c/code\u003e 連接\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eLaravel\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003erole_user\u003c/code\u003e, \u003ccode\u003epermission_role\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e字母順序排列\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eTypeORM\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003euser_roles_role\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e較長的命名\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003ePrisma\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e_UserToRole\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e以 \u003ccode\u003e_\u003c/code\u003e 開頭\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eSequelize\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003eUserRoles\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e駝峰命名\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch3 id=\"問題的本質資料不完整\"\u003e問題的本質：資料不完整\u003c/h3\u003e\n\u003cp\u003e當我們同步資料庫時，通常會注意主要的資料表：\u003c/p\u003e","title":"資料庫同步的隱藏陷阱：Link Table 的重要性"},{"content":"前言：當建置一直紅燈 CI/CD pipeline 亮紅燈是開發日常，但連續遇到三個不同層面的問題，從 iOS codesigning、Android Gradle、到 Google Play API，這就值得記錄下來了。\n這篇文章記錄我在同一天內遇到的三個建置失敗，以及逐步排除的過程。每個問題都有其獨特的根因，但也反映出 CI/CD 環境的複雜性。\n問題一：iOS Keychain 解鎖失敗 症狀 Jenkins 建置在 iOS 階段失敗，錯誤訊息：\n[!] Error unlocking keychain at path: fastlane_keychain Command failed with exit status 51 macOS Keychain 運作機制 在深入問題之前，先了解 macOS Keychain 的運作方式：\n關鍵概念：\nmacOS 可以有多個 Keychain，每個都有獨立密碼 憑證必須在「已解鎖」的 Keychain 中才能被 codesign 使用 CI 環境通常會建立專用的 Keychain，避免影響系統 Keychain Fastlane Match 與 Keychain 的互動流程 調查過程 Exit status 51 代表「密碼錯誤」。SSH 進 Jenkins Mac mini 確認：\n# 確認 keychain 存在 security list-keychains # 輸出包含 fastlane_keychain-db # 確認憑證存在 security find-identity -v -p codesigning fastlane_keychain # 輸出：Apple Distribution: XXXXXXX Inc. (XXXXXXXX) 問題出在 Fastfile 的邏輯：\nunlock_keychain( path: \u0026#34;fastlane_keychain\u0026#34;, password: ENV[\u0026#39;KEYCHAIN_PASSWORD\u0026#39;] || \u0026#34;temp_password\u0026#34;, set_default: false ) 根本原因 密碼來源的優先順序問題：\nKeychain 實際密碼是 temp_password，但 Jenkins Credential KEYCHAIN_PASSWORD 被設定成其他值。Ruby 的 || 運算子在 ENV['KEYCHAIN_PASSWORD'] 有值時不會 fallback。\n解決方案 直接硬編碼密碼，繞過可能設定錯誤的環境變數：\nunlock_keychain( path: \u0026#34;fastlane_keychain\u0026#34;, password: \u0026#34;temp_password\u0026#34;, # 直接使用，避免 credential 設定錯誤 set_default: false ) 同時修復 unsetup_ci 函式，避免刪除持久的 keychain：\ndef unsetup_ci # 只刪除 setup_ci 創建的臨時 keychain temp_keychain = \u0026#34;fastlane_tmp_keychain\u0026#34; if File.exist?(File.expand_path(\u0026#34;~/Library/Keychains/#{temp_keychain}-db\u0026#34;)) delete_keychain(name: temp_keychain) end end 教訓 CI 環境的 credential 管理要有單一真相來源。 當 keychain 密碼同時存在於 Jenkins Credentials 和程式碼中，不一致就會發生。\n問題二：file_picker Gradle 相容性 症狀 iOS 建置成功後，Android 建置失敗：\nA problem occurred evaluating project \u0026#39;:file_picker\u0026#39;. \u0026gt; Cannot invoke method substring() on null object Build file: file_picker-10.3.8/android/build.gradle line: 30 Flutter Plugin 版本解析流程 根本原因 這是 Flutter 版本相容性問題。file_picker 10.3.x 使用了 Flutter 3.27+ 才有的 Gradle 屬性，但 Jenkins 環境使用的是 Flutter 3.24.5。\n解決方案 在 pubspec.yaml 中鎖定版本：\ndependencies: file_picker: 10.1.9 # 移除 ^，鎖定相容版本 教訓 版本約束要考慮 CI 環境的 Flutter 版本。 使用 ^ 語義版本時，要注意 minor version 升級可能帶來的相容性問題。\n問題三：Google Play 草稿狀態限制 症狀 iOS 上傳 TestFlight 成功，Android AAB 也建置成功，但上傳 Google Play 失敗：\nGoogle Api Error: Invalid request - Only releases with status draft may be created on draft app. Google Play Console App 狀態機 根本原因 STG app 在 Google Play Console 中尚未完成首次發布流程，還處於「草稿狀態」。在這個狀態下，Google Play API 不允許創建 completed 狀態的版本。\n解決方案 將 release_status 改為 draft：\nsupply( package_name: \u0026#34;com.example.app.stg\u0026#34;, aab: \u0026#34;../build/app/outputs/bundle/stagingRelease/app-staging-release.aab\u0026#34;, track: \u0026#39;internal\u0026#39;, release_status: \u0026#39;draft\u0026#39;, # 草稿 app 只能用 draft json_key_data: ENV[\u0026#39;SUPPLY_JSON_KEY_DATA\u0026#39;] ) 教訓 不同環境的 app 可能處於不同的 Play Console 狀態。 Production app 已經發布過，可以用 completed；但 STG/DEV app 可能還是草稿。\n總結：CI/CD 的複雜性 一天內遇到三個不同層面的問題：\n層面 問題 根因 iOS Signing Keychain 解鎖失敗 Credential 與實際密碼不一致 Android Build Gradle 編譯錯誤 Plugin 版本與 Flutter 版本不相容 Play Store API 上傳被拒絕 App 狀態與 API 參數不匹配 這些問題的共同點是：CI 環境與本地開發環境的差異。\n解決這類問題的關鍵是：\n詳讀錯誤訊息：exit status 51、substring on null、draft app 都是明確的線索 SSH 進 CI 環境驗證：不要猜測，直接進去看 理解背後的機制：Keychain 如何運作、Gradle 如何解析版本、Play Console 的狀態機 修復後驗證：每次只改一個東西，確認是否解決 相關資源 Fastlane match 文件 Flutter 版本相容性 Google Play Developer API - releases ","permalink":"https://peter-notes.com/posts/flutter-cicd-three-build-failures/","summary":"\u003ch2 id=\"前言當建置一直紅燈\"\u003e前言：當建置一直紅燈\u003c/h2\u003e\n\u003cp\u003eCI/CD pipeline 亮紅燈是開發日常，但連續遇到三個不同層面的問題，從 iOS codesigning、Android Gradle、到 Google Play API，這就值得記錄下來了。\u003c/p\u003e\n\u003cp\u003e這篇文章記錄我在同一天內遇到的三個建置失敗，以及逐步排除的過程。每個問題都有其獨特的根因，但也反映出 CI/CD 環境的複雜性。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"問題一ios-keychain-解鎖失敗\"\u003e問題一：iOS Keychain 解鎖失敗\u003c/h2\u003e\n\u003ch3 id=\"症狀\"\u003e症狀\u003c/h3\u003e\n\u003cp\u003eJenkins 建置在 iOS 階段失敗，錯誤訊息：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e[!] Error unlocking keychain at path: fastlane_keychain\nCommand failed with exit status 51\n\u003c/code\u003e\u003c/pre\u003e\u003ch3 id=\"macos-keychain-運作機制\"\u003emacOS Keychain 運作機制\u003c/h3\u003e\n\u003cp\u003e在深入問題之前，先了解 macOS Keychain 的運作方式：\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/flutter-cicd-three-build-failures-diagram-f874cac1.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e\u003cstrong\u003e關鍵概念：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003emacOS 可以有多個 Keychain，每個都有獨立密碼\u003c/li\u003e\n\u003cli\u003e憑證必須在「已解鎖」的 Keychain 中才能被 \u003ccode\u003ecodesign\u003c/code\u003e 使用\u003c/li\u003e\n\u003cli\u003eCI 環境通常會建立專用的 Keychain，避免影響系統 Keychain\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"fastlane-match-與-keychain-的互動流程\"\u003eFastlane Match 與 Keychain 的互動流程\u003c/h3\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/flutter-cicd-three-build-failures-diagram-2a52d3b1.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003ch3 id=\"調查過程\"\u003e調查過程\u003c/h3\u003e\n\u003cp\u003eExit status 51 代表「密碼錯誤」。SSH 進 Jenkins Mac mini 確認：\u003c/p\u003e","title":"Flutter CI/CD Debugging: Three Build Failures in One Day"},{"content":"前言：一個看似簡單的需求 需求很單純：讓 Email 驗證信中的連結顯示前端網址（www.example.com），而不是後台網址（api.example.com）。這樣用戶不會看到內部系統架構。\n外包商寫了這段程式碼：\n// ❌ 看起來合理，但完全沒效果 plugin.services.user.sendConfirmationEmail = async function(user) { // 自訂邏輯... } 部署後，Email 中的連結依然指向後台。為什麼？\n問題根源：Factory Function 陷阱 翻開 Strapi 原始碼，發現 plugin.services.user 不是一個物件，而是一個 factory function：\n// Strapi 內部實作（簡化版） plugin.services.user = (context) =\u0026gt; { return { sendConfirmationEmail: async (user) =\u0026gt; { /* 原始邏輯 */ }, // 其他方法... } } 這意味著什麼？\n每次 Strapi 需要 user service 時，它會呼叫這個 factory function 來取得一個新的 service 實例。你直接覆蓋 sendConfirmationEmail 屬性，等於在一個 function 物件上加屬性——factory 被呼叫時根本不會讀取這個屬性。\n不這樣理解會怎樣？ 你會像外包商一樣，花兩天 debug 卻找不到原因，因為程式碼完全沒有報錯，只是靜靜地被忽略。\n解決方案：Factory Wrapper 模式 正確的做法是包裝原本的 factory function：\n// ✅ Factory Wrapper 模式 const originalUserServiceFactory = plugin.services.user; plugin.services.user = (context) =\u0026gt; { // 先取得原始 service 實例 const originalUserService = originalUserServiceFactory(context); return { ...originalUserService, // 保留所有原始方法 // 覆寫特定方法 async sendConfirmationEmail(user) { // 你的自訂邏輯 const confirmationUrl = `${FRONTEND_URL}/verifyEmail`; // ... }, }; }; 為什麼這樣有效？\n保存原始 factory：originalUserServiceFactory 是對原始 factory 的引用 呼叫原始 factory：取得完整的原始 service 實例 展開並覆寫：用 spread operator 保留原始方法，只覆寫需要的部分 關鍵洞見：你不是在覆蓋一個方法，而是在替換整個 factory function，讓它返回你修改過的 service。\n用比喻理解 Factory Wrapper 想像一家汽車工廠：\n做法 比喻 結果 ❌ 直接覆寫 在工廠大門貼一張「我們的車會飆車」的紙條 工廠內部還是照原本方式生產，紙條沒人看 ✅ Factory Wrapper 在工廠外面再蓋一層，攔截每台出廠的車，改裝後再交給客戶 每台車都被改裝過 Factory Wrapper 重點整理 概念 說明 Factory Function 每次呼叫都會產生新物件的函式 為什麼直接覆寫沒用 你改的是函式本身，不是它 return 的物件 Factory Wrapper 用新 factory 包住舊 factory，攔截並修改輸出 核心步驟 保存 → 替換 → 呼叫原始 → 展開 → 覆寫 第二個陷阱：Email 模板的 Query String 修好了 factory wrapper，結果收到的 Email 長這樣：\nhttps://example.com/verifyEmail?confirmation=abc123?confirmation=abc123 為什麼有兩個 ?confirmation=？\nStrapi 的 email 模板使用變數：\n\u0026lt;a href=\u0026#34;\u0026lt;%= URL %\u0026gt;?confirmation=\u0026lt;%= CODE %\u0026gt;\u0026#34;\u0026gt;驗證\u0026lt;/a\u0026gt; 而我的程式碼傳入：\n{ URL: `${FRONTEND_URL}/verifyEmail?confirmation=${token}`, // ❌ 已經有 query string CODE: token, } 模板把 ?confirmation= 又加了一次。\n修正：URL 只傳基底路徑，讓模板負責加 query string：\n{ URL: `${FRONTEND_URL}/verifyEmail`, // ✅ 只有路徑 CODE: token, } 第三個陷阱：錯誤的 Nginx 設定檔 要讓前端 URL 可以驗證，需要 Nginx 做重導向。我修改了 docker/nginx/prod.conf，但部署後沒生效。\n檢查 Dockerfile：\n# 實際使用的是這個檔案！ COPY ./docker/nginx/bot-routing.conf /etc/nginx/http.d/default.conf 我改錯檔案了。 prod.conf 根本沒被使用。\n教訓：修改設定前，先確認 Dockerfile 實際使用哪個檔案。\n補充：什麼是反向代理？ 在這個解決方案中，Nginx 扮演「反向代理」的角色。讓我用比喻解釋：\n正向代理（Forward Proxy）：你的代理人 想像你要買一棟豪宅，但不想讓賣家知道是你在買（怕抬價）。你請了一個代理人（律師）出面談判，賣家只知道「有個律師要買房」，不知道背後是誰。\n正向代理就是這樣：\n你（Client） 知道自己要連哪個網站 代理人（Proxy） 幫你去連 網站（Server） 只看到代理人，不知道你是誰 常見用途：翻牆、隱藏身份、公司網路管控。\n反向代理（Reverse Proxy）：總機小姐 想像你打電話到一家大公司，接電話的是總機小姐。你說「我要找技術支援」，總機幫你轉接到正確的部門。你不知道（也不需要知道）技術支援部門的直撥號碼。\n反向代理就是這樣：\n你（Client） 只知道一個入口（總機） 總機（Nginx） 根據你的需求，轉接到對應的後端服務 後端服務（Server） 對外隱藏，只有總機知道怎麼找到它們 在我們的案例中：\n用戶點擊: www.example.com/verifyEmail ↓ [Nginx 反向代理] 「喔，你要驗證 Email？我幫你轉給後台 API」 ↓ api.example.com/api/auth/email-confirmation 為什麼要這樣做？\n安全性：用戶不知道後台網址，減少攻擊面 靈活性：後台可以隨時搬遷，只要改 Nginx 設定 形象：用戶看到的都是漂亮的前端網址 反向代理解決什麼問題？ 回到我們的需求：Email 驗證信的連結。\n原本的流程（沒有反向代理）：\nEmail 連結: api.example.com/api/auth/email-confirmation?confirmation=TOKEN ↓ 用戶心裡: 「api 是什麼？這網址看起來怪怪的...」 加入 Nginx 後：\nEmail 連結: www.example.com/verifyEmail?confirmation=TOKEN ↓ [Nginx 反向代理] ↓ 實際處理: api.example.com/api/auth/email-confirmation?confirmation=TOKEN ↓ 用戶心裡: 「正常的網址，沒什麼特別的」 問題 沒有反向代理 有反向代理 用戶體驗 看到奇怪的 api. 網址 只看到熟悉的前端網址 安全性 後台網址暴露 後台網址隱藏 靈活性 換後台要改 Email 模板 換後台只要改 Nginx 設定 完整架構 最終解決方案需要兩個元件配合：\nStrapi（產生前端 URL）：\n// strapi-server.ts const confirmationUrl = `${FRONTEND_URL}/verifyEmail`; // Email 模板會加上 ?confirmation=TOKEN Nginx（反向代理到後端 API）：\n# bot-routing.conf map $host $backend_api_url { \u0026#34;~*stg-www.example.com\u0026#34; \u0026#34;https://stg-api.example.com\u0026#34;; \u0026#34;~*www.example.com\u0026#34; \u0026#34;https://api.example.com\u0026#34;; } location = /verifyEmail { if ($arg_confirmation) { return 302 $backend_api_url/api/auth/email-confirmation?confirmation=$arg_confirmation; } } 流程：\n用戶點擊 www.example.com/verifyEmail?confirmation=TOKEN Nginx（總機）302 重導向到 api.example.com/api/auth/email-confirmation Strapi 驗證 token 並更新用戶狀態 用戶回到 App，按「下一步」成功 關鍵學習 陷阱 症狀 解法 Factory Function Override 沒效果，無報錯 Factory Wrapper 模式 Email 模板變數 Query string 重複 URL 只傳路徑 錯誤的設定檔 設定沒生效 先檢查 Dockerfile 最重要的洞見：當你的 override「沒有報錯但也沒效果」時，很可能是你在覆寫一個 factory function，而不是一個普通物件。去看原始碼，確認你要覆寫的東西是什麼型別。\n這個 debug 過程花了一整天，希望這篇文章能幫你省下這一天。\n","permalink":"https://peter-notes.com/posts/strapi-plugin-factory-wrapper/","summary":"\u003ch2 id=\"前言一個看似簡單的需求\"\u003e前言：一個看似簡單的需求\u003c/h2\u003e\n\u003cp\u003e需求很單純：讓 Email 驗證信中的連結顯示前端網址（\u003ccode\u003ewww.example.com\u003c/code\u003e），而不是後台網址（\u003ccode\u003eapi.example.com\u003c/code\u003e）。這樣用戶不會看到內部系統架構。\u003c/p\u003e\n\u003cp\u003e外包商寫了這段程式碼：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// ❌ 看起來合理，但完全沒效果\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"nx\"\u003eplugin\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eservices\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003euser\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003esendConfirmationEmail\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kr\"\u003easync\u003c/span\u003e \u003cspan class=\"kd\"\u003efunction\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003euser\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"c1\"\u003e// 自訂邏輯...\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e部署後，Email 中的連結依然指向後台。\u003cstrong\u003e為什麼？\u003c/strong\u003e\u003c/p\u003e\n\u003ch2 id=\"問題根源factory-function-陷阱\"\u003e問題根源：Factory Function 陷阱\u003c/h2\u003e\n\u003cp\u003e翻開 Strapi 原始碼，發現 \u003ccode\u003eplugin.services.user\u003c/code\u003e 不是一個物件，而是一個 \u003cstrong\u003efactory function\u003c/strong\u003e：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// Strapi 內部實作（簡化版）\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"nx\"\u003eplugin\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eservices\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003euser\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003econtext\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nx\"\u003esendConfirmationEmail\u003c/span\u003e: \u003cspan class=\"kt\"\u003easync\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003euser\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"cm\"\u003e/* 原始邏輯 */\u003c/span\u003e \u003cspan class=\"p\"\u003e},\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"c1\"\u003e// 其他方法...\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e這意味著什麼？\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e每次 Strapi 需要 user service 時，它會\u003cstrong\u003e呼叫這個 factory function\u003c/strong\u003e 來取得一個新的 service 實例。你直接覆蓋 \u003ccode\u003esendConfirmationEmail\u003c/code\u003e 屬性，等於在一個 function 物件上加屬性——factory 被呼叫時根本不會讀取這個屬性。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e不這樣理解會怎樣？\u003c/strong\u003e 你會像外包商一樣，花兩天 debug 卻找不到原因，因為程式碼完全沒有報錯，只是靜靜地被忽略。\u003c/p\u003e\n\u003ch2 id=\"解決方案factory-wrapper-模式\"\u003e解決方案：Factory Wrapper 模式\u003c/h2\u003e\n\u003cp\u003e正確的做法是\u003cstrong\u003e包裝原本的 factory function\u003c/strong\u003e：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// ✅ Factory Wrapper 模式\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"kr\"\u003econst\u003c/span\u003e \u003cspan class=\"nx\"\u003eoriginalUserServiceFactory\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nx\"\u003eplugin\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eservices\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003euser\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nx\"\u003eplugin\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eservices\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003euser\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003econtext\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"c1\"\u003e// 先取得原始 service 實例\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e  \u003cspan class=\"kr\"\u003econst\u003c/span\u003e \u003cspan class=\"nx\"\u003eoriginalUserService\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nx\"\u003eoriginalUserServiceFactory\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003econtext\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e...\u003c/span\u003e\u003cspan class=\"nx\"\u003eoriginalUserService\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e  \u003cspan class=\"c1\"\u003e// 保留所有原始方法\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"c1\"\u003e// 覆寫特定方法\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e    \u003cspan class=\"kr\"\u003easync\u003c/span\u003e \u003cspan class=\"nx\"\u003esendConfirmationEmail\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003euser\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"c1\"\u003e// 你的自訂邏輯\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e      \u003cspan class=\"kr\"\u003econst\u003c/span\u003e \u003cspan class=\"nx\"\u003econfirmationUrl\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"sb\"\u003e`\u003c/span\u003e\u003cspan class=\"si\"\u003e${\u003c/span\u003e\u003cspan class=\"nx\"\u003eFRONTEND_URL\u003c/span\u003e\u003cspan class=\"si\"\u003e}\u003c/span\u003e\u003cspan class=\"sb\"\u003e/verifyEmail`\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"c1\"\u003e// ...\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e    \u003cspan class=\"p\"\u003e},\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e};\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e};\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e為什麼這樣有效？\u003c/strong\u003e\u003c/p\u003e","title":"Strapi Plugin 覆寫陷阱：為什麼你的 Override 沒有生效？"},{"content":"接手專案，先看帳單 因為老闆信用卡到期了要換新卡，我順便看了一下 AWS 帳單金額，發現比預期高。之前詢問外包商技術長（已離職），得到的回覆是：「服務都已經從新加坡遷移到台北了，除了 S3 有保留做備份，其他都刪除了。」\n身為工程師，最不能接受的就是「應該是這樣」。我決定親自盤點。\n名詞解釋 在繼續之前，先解釋一下會提到的 AWS 服務：\n服務 說明 費用特性 S3 (Simple Storage Service) 物件儲存服務，用來存放檔案、圖片、影片 按儲存容量和請求次數計費 NAT Gateway 讓私有子網路的資源能存取網際網路 按小時計費，即使沒流量也要錢 Elastic IP 固定的公開 IP 位址 使用中免費，未關聯則收費 VPC (Virtual Private Cloud) 虛擬私有網路，隔離你的雲端資源 VPC 本身免費，但相關資源收費 Network Load Balancer 負載平衡器，分散流量到多台伺服器 按小時和處理的資料量計費 ECR (Elastic Container Registry) Docker 映像檔儲存庫 按儲存容量計費 重點是：有些資源即使沒有流量，只要存在就會收費。NAT Gateway 和未關聯的 Elastic IP 就是典型的「隱形殺手」。\n盤點遺留資源 # 檢查 EKS 叢集（Kubernetes 服務） aws eks list-clusters --region ap-southeast-1 # 結果：空的 ✓ # 檢查 RDS（資料庫） aws rds describe-db-instances --region ap-southeast-1 # 結果：空的 ✓ # 檢查 NAT Gateway aws ec2 describe-nat-gateways --region ap-southeast-1 \\ --filter \u0026#34;Name=state,Values=available\u0026#34; # 結果：2 個還在跑 完整盤點結果：\n資源類型 數量 月費估算 NAT Gateway 2 ~$65 Network LB 1 ~$16 Elastic IP (未關聯) 2 ~$7 VPC 4 - ECR Repository 5 - S3 Bucket 1 ~$2 合計 ~$90/月 EKS 和 RDS 確實刪了，但網路層的資源全部還在。這就是口頭交接的風險——沒有文件記錄，就會有遺漏。\n清理閒置資源 確認這些資源沒有在用（DNS 已指向台北、Target Group 是空的），開始清理：\n# 刪除 Load Balancer aws elbv2 delete-load-balancer --load-balancer-arn $LB_ARN # 刪除 NAT Gateway aws ec2 delete-nat-gateway --nat-gateway-id nat-07fb8958f69654f50 # 釋放 Elastic IP（NAT Gateway 刪除後才能釋放） aws ec2 release-address --allocation-id eipalloc-xxxxx # 刪除 VPC（需按相依性順序） # Subnet → Internet Gateway → Route Table → Security Group → VPC S3 的部分，先確認台北資料完整：\n# 台北 S3：72.9 GiB，316,000 個檔案 # 新加坡 S3：49.6 GiB，2,300 個檔案 # 台北資料更完整，可以清理新加坡的 aws s3 rb s3://prod-s3-singapore --force 發現 Strapi 的 URL 儲存機制 清理完成後，我主動檢查網站功能，發現部分圖片載入失敗。打開 DevTools 查看：\nGET https://prod-s3-singapore.s3.ap-southeast-1.amazonaws.com/image_abc123.png → 404 Not Found 有些 URL 還是指向舊的 S3。\n排查資料庫 這個專案使用 Strapi CMS，檔案資訊存在 PostgreSQL。先檢查主要的 files 表：\nSELECT COUNT(*) FROM files WHERE url LIKE \u0026#39;%ap-southeast-1%\u0026#39;; -- 結果：0 主要 URL 欄位是乾淨的。但問題出在 Strapi 的縮圖機制。\nStrapi 的多層 URL 儲存 Strapi 上傳圖片時，會自動產生多種尺寸的縮圖。這些 URL 存在 formats 欄位（JSONB 格式）：\n{ \u0026#34;thumbnail\u0026#34;: { \u0026#34;url\u0026#34;: \u0026#34;https://prod-s3-singapore.s3.../thumbnail_abc.png\u0026#34;, \u0026#34;width\u0026#34;: 156, \u0026#34;height\u0026#34;: 156 }, \u0026#34;small\u0026#34;: { \u0026#34;url\u0026#34;: \u0026#34;https://prod-s3-singapore.s3.../small_abc.png\u0026#34;, \u0026#34;width\u0026#34;: 500, \u0026#34;height\u0026#34;: 500 } } 系統性搜尋後，找到 URL 分散在四個地方：\n位置 筆數 說明 files.url 0 主要 URL（遷移時已處理） files.formats 419 縮圖 URL（JSONB） layout_d_singles.content 12 頁面內容（HTML） layout_d_singles.content_searchable 12 搜尋索引 這就是遷移時的技術債——只更新了表面的 URL，沒有處理到 JSONB 內的巢狀資料和富文本內容。\n批次修復 針對不同的資料類型，使用對應的更新語法：\n-- JSONB 欄位：需要轉型處理 UPDATE files SET formats = REPLACE( formats::text, \u0026#39;prod-s3-singapore.s3.ap-southeast-1.amazonaws.com\u0026#39;, \u0026#39;prod-s3-taipei.s3.ap-east-2.amazonaws.com\u0026#39; )::jsonb WHERE formats::text LIKE \u0026#39;%ap-southeast-1%\u0026#39;; -- UPDATE 419 -- TEXT 欄位：直接替換 UPDATE layout_d_singles SET content = REPLACE( content, \u0026#39;prod-s3-singapore.s3.ap-southeast-1.amazonaws.com\u0026#39;, \u0026#39;prod-s3-taipei.s3.ap-east-2.amazonaws.com\u0026#39; ) WHERE content LIKE \u0026#39;%ap-southeast-1%\u0026#39;; -- UPDATE 12 重啟服務清除快取：\nkubectl rollout restart deployment/strapi-prod kubectl rollout restart deployment/web-prod 全部功能恢復正常。\nStrapi 遷移檢查清單 基於這次經驗，整理出 Strapi 專案遷移時需要檢查的 URL 位置：\n-- 1. 主要 URL SELECT COUNT(*) FROM files WHERE url LIKE \u0026#39;%舊網域%\u0026#39;; -- 2. 縮圖 URL（JSONB） SELECT COUNT(*) FROM files WHERE formats::text LIKE \u0026#39;%舊網域%\u0026#39;; -- 3. 富文本內容（各種 layout 表） SELECT COUNT(*) FROM layout_a_singles WHERE content LIKE \u0026#39;%舊網域%\u0026#39;; SELECT COUNT(*) FROM layout_b_singles WHERE block_1 LIKE \u0026#39;%舊網域%\u0026#39;; SELECT COUNT(*) FROM layout_c_singles WHERE content LIKE \u0026#39;%舊網域%\u0026#39;; SELECT COUNT(*) FROM layout_d_singles WHERE content LIKE \u0026#39;%舊網域%\u0026#39;; -- 4. 搜尋索引 SELECT COUNT(*) FROM layout_d_singles WHERE content_searchable LIKE \u0026#39;%舊網域%\u0026#39;; 遷移的正確順序 下次遷移時，記得按這個順序：\n確認新區域資料完整 搜尋並更新所有舊 URL ← 這步最容易漏掉 測試所有功能正常 最後才刪除舊資源 總結 這次技術債清理的收穫：\n每月省下 $90：清掉遺留的 NAT Gateway、LB、EIP 完成遷移收尾：修復 500+ 筆遺漏的 URL 建立檢查清單：未來 Strapi 遷移有標準流程 接手專案時，帳單是最好的健檢報告。有疑慮就自己查，不要只靠口頭確認。\n","permalink":"https://peter-notes.com/posts/aws-s3-cleanup-pitfall/","summary":"\u003ch2 id=\"接手專案先看帳單\"\u003e接手專案，先看帳單\u003c/h2\u003e\n\u003cp\u003e因為老闆信用卡到期了要換新卡，我順便看了一下 AWS 帳單金額，發現比預期高。之前詢問外包商技術長（已離職），得到的回覆是：「服務都已經從新加坡遷移到台北了，除了 S3 有保留做備份，其他都刪除了。」\u003c/p\u003e\n\u003cp\u003e身為工程師，最不能接受的就是「應該是這樣」。我決定親自盤點。\u003c/p\u003e\n\u003ch2 id=\"名詞解釋\"\u003e名詞解釋\u003c/h2\u003e\n\u003cp\u003e在繼續之前，先解釋一下會提到的 AWS 服務：\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e服務\u003c/th\u003e\n          \u003cth\u003e說明\u003c/th\u003e\n          \u003cth\u003e費用特性\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eS3 (Simple Storage Service)\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e物件儲存服務，用來存放檔案、圖片、影片\u003c/td\u003e\n          \u003ctd\u003e按儲存容量和請求次數計費\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eNAT Gateway\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e讓私有子網路的資源能存取網際網路\u003c/td\u003e\n          \u003ctd\u003e\u003cstrong\u003e按小時計費\u003c/strong\u003e，即使沒流量也要錢\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eElastic IP\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e固定的公開 IP 位址\u003c/td\u003e\n          \u003ctd\u003e使用中免費，\u003cstrong\u003e未關聯則收費\u003c/strong\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eVPC (Virtual Private Cloud)\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e虛擬私有網路，隔離你的雲端資源\u003c/td\u003e\n          \u003ctd\u003eVPC 本身免費，但相關資源收費\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eNetwork Load Balancer\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e負載平衡器，分散流量到多台伺服器\u003c/td\u003e\n          \u003ctd\u003e按小時和處理的資料量計費\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eECR (Elastic Container Registry)\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003eDocker 映像檔儲存庫\u003c/td\u003e\n          \u003ctd\u003e按儲存容量計費\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e重點是：\u003cstrong\u003e有些資源即使沒有流量，只要存在就會收費\u003c/strong\u003e。NAT Gateway 和未關聯的 Elastic IP 就是典型的「隱形殺手」。\u003c/p\u003e\n\u003ch2 id=\"盤點遺留資源\"\u003e盤點遺留資源\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 檢查 EKS 叢集（Kubernetes 服務）\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eaws eks list-clusters --region ap-southeast-1\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 結果：空的 ✓\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 檢查 RDS（資料庫）\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eaws rds describe-db-instances --region ap-southeast-1\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 結果：空的 ✓\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 檢查 NAT Gateway\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eaws ec2 describe-nat-gateways --region ap-southeast-1 \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e  --filter \u003cspan class=\"s2\"\u003e\u0026#34;Name=state,Values=available\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 結果：2 個還在跑\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e完整盤點結果：\u003c/p\u003e","title":"AWS 跨區域遷移後的技術債清理：Strapi URL 的隱藏陷阱"},{"content":"起因：老闆想省錢 「Staging 環境平常沒人用，每個月還要燒 $45-60 美金，能不能想辦法省一點？」\nStaging 環境的成本來自兩個地方：RDS 資料庫（約 $15-20/月）和 EKS 節點（約 $30-40/月）。既然平常沒在用，我想到了一個方案：不用的時候關掉，需要的時候再打開。\n於是我寫了兩個腳本：\nstaging-start.sh：啟動 RDS、擴充節點、部署應用 staging-stop.sh：刪除部署、縮減節點、停止 RDS # staging-stop.sh 核心邏輯 kubectl delete deployment app-strapi-stg app-web-stg aws eks update-nodegroup-config \\ --cluster-name my-cluster \\ --nodegroup-name my-nodegroup \\ --scaling-config minSize=0,maxSize=2,desiredSize=1 # 從 2 縮到 1 aws rds stop-db-instance --db-instance-identifier my-stg-rds 看起來很合理，但這裡有個問題：Production 和 Staging 共用同一個 nodegroup。\n踩坑：AWS 隨機選擇刪除節點 執行 staging-stop.sh 縮減節點時，AWS 會隨機選擇要終止哪個節點。當時的配置：\n節點 A：運行 Production pods 節點 B：運行 Staging pods 我期望刪除節點 B，但 AWS 選了節點 A。Production pods 被強制遷移，觸發了重新調度。\n這本身不是大問題，Kubernetes 會自動重建 pods。但在嘗試修復時，我犯了另一個錯誤：直接 kubectl apply 了一個過時的 YAML 檔案。\n檔案中的 image 版本是舊的 0.73，而實際運行的是 1.134。舊版應用程式啟動時執行了 schema migration，清空了部分資料欄位和權限設定。\n快速修復：RDS 備份 + Sequence 同步 幸好 RDS 有自動備份，修復過程約一小時：\n# 1. 從昨天的 snapshot 建立臨時 RDS aws rds restore-db-instance-from-db-snapshot \\ --db-instance-identifier my-recovery-rds \\ --db-snapshot-identifier rds:my-rds-2026-01-05-21-02 # 2. 匯出需要的資料 pg_dump -h recovery-rds.xxx.rds.amazonaws.com \\ -t animations -t probiotics -t user_unlocked_animations \\ --data-only \u0026gt; backup_data.sql # 3. 匯入到 Production psql -h prod-rds.xxx.rds.amazonaws.com -f backup_data.sql 但匯入後 App 還是異常。原因是 PostgreSQL sequence 沒同步：\n-- 資料表 MAX(id) = 1,645，但 sequence 還停在舊值 -- 新資料會嘗試用已存在的 id，導致 duplicate key error -- 修復：同步 sequence 到 MAX(id) SELECT setval( \u0026#39;user_unlocked_animations_id_seq\u0026#39;, (SELECT MAX(id) FROM user_unlocked_animations), true ); 另外還要恢復 Strapi 的 API 權限表 up_permissions，App 才能正常存取 API。\n什麼是 Nodegroup？為什麼要分開？ 在解釋解法之前，先釐清 EKS Nodegroup 的概念。\nNodegroup 是 EKS 管理的一組 EC2 節點，定義了：\n節點的機器規格（instance type） 自動擴縮配置（min/max/desired） 節點標籤（labels）和污點（taints） IAM 角色和安全群組 共用 vs 獨立 Nodegroup 【共用 Nodegroup】 【獨立 Nodegroup】 ┌─────────────────────────┐ ┌──────────────┐ ┌──────────────┐ │ my-nodegroup │ │ prod-nodes │ │ stg-nodes │ │ ┌─────┐ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │ │ │Prod │ │ Stg │ │ │ │Prod │ │ │ │ Stg │ │ │ │Pods │ │Pods │ │ │ │Pods │ │ │ │Pods │ │ │ └─────┘ └─────┘ │ │ └─────┘ │ │ └─────┘ │ └─────────────────────────┘ └──────────────┘ └──────────────┘ 縮減時 AWS 隨機選節點 縮減 stg-nodes 不影響 prod-nodes 面向 共用 Nodegroup 獨立 Nodegroup 成本 較低（節點共用） 稍高（各自節點） 隔離性 無隔離，互相影響 完全隔離 擴縮控制 無法針對環境操作 可獨立擴縮各環境 風險 操作一個影響全部 操作範圍受限 結論：省下的幾塊錢節點費用，換來的是操作風險。Production 和 Staging 應該用獨立 Nodegroup。\nAWS 有什麼工具能避免這種情況？ 除了獨立 Nodegroup，AWS/Kubernetes 還提供幾個防護機制：\n1. Pod Disruption Budget (PDB) PDB 限制同時可被中斷的 pods 數量，防止縮減節點時影響服務：\napiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: prod-pdb spec: minAvailable: 1 # 至少保留 1 個 pod 運行 selector: matchLabels: env: prod 當 AWS 嘗試終止節點時，如果會違反 PDB，操作會被阻擋或延遲。\n2. Node Taints + Tolerations 比 nodeSelector 更強的隔離機制。Taint 會「排斥」pods，只有帶對應 Toleration 的 pods 才能調度上去：\n# 給 production 節點加上 taint kubectl taint nodes prod-node-1 env=prod:NoSchedule # Production pods 加上 toleration 才能調度 spec: tolerations: - key: \u0026#34;env\u0026#34; operator: \u0026#34;Equal\u0026#34; value: \u0026#34;prod\u0026#34; effect: \u0026#34;NoSchedule\u0026#34; 3. AWS Node Termination Handler 處理 Spot Instance 中斷和節點維護事件，在節點被終止前安全遷移 pods：\nhelm install aws-node-termination-handler \\ eks/aws-node-termination-handler \\ --namespace kube-system 4. Karpenter（進階） AWS 推出的智慧節點管理工具，比傳統 Cluster Autoscaler 更靈活：\n自動選擇最適合的 instance type 更快的擴縮反應時間 支援 Spot Instance 混合使用 這次事件最適合的防護 工具 能否防止這次問題 說明 獨立 Nodegroup ✅ 完全防止 從根本隔離環境 PDB ⚠️ 部分防止 可延遲節點終止，但不能阻止 Taints ✅ 可防止 確保 pods 只在對的節點 Node Termination Handler ❌ 無法防止 主要處理 Spot 中斷 最佳實踐：獨立 Nodegroup + NodeSelector + PDB 三層防護。\n正確做法：獨立 Nodegroup + NodeSelector 省錢的需求是合理的，但架構要正確。最終方案是建立獨立的 Staging nodegroup：\n# 建立獨立的 Staging nodegroup（初始 0 個節點） aws eks create-nodegroup \\ --cluster-name my-cluster \\ --nodegroup-name my-cluster-nodes-staging \\ --scaling-config minSize=0,maxSize=2,desiredSize=0 \\ --labels env=stg 並在 Deployment 加入 nodeSelector 確保 pods 調度到正確節點：\n# Production spec: template: spec: nodeSelector: env: prod # Staging spec: template: spec: nodeSelector: env: stg 現在 staging-stop.sh 只操作 Staging nodegroup，不影響 Production：\naws eks update-nodegroup-config \\ --nodegroup-name my-cluster-nodes-staging \\ --scaling-config desiredSize=0 加入團隊規範的鐵則 這次經驗讓我在團隊規範加入一條鐵則：\nkubectl apply 前必須確認 image 版本\n# 套用前先確認目前運行的版本 kubectl get deployment my-app -o jsonpath=\u0026#39;{.spec.template.spec.containers[0].image}\u0026#39; # 比對 YAML 檔案中的版本 grep \u0026#34;image:\u0026#34; deployment.yaml # 版本一致才能 apply 總結 問題 解法 共用 nodegroup 導致縮減時刪錯節點 建立獨立的 Staging nodegroup 沒有環境隔離機制 使用 nodeSelector 綁定 pods 到特定節點 YAML 檔案版本過時 部署前必須確認 image 版本 資料恢復後仍異常 同步 PostgreSQL sequence + 恢復權限表 省錢是好事，但要用正確的架構來實現。獨立 nodegroup + nodeSelector 的組合，既能按需開關 Staging 環境省錢，又不會影響 Production。\n最後感謝 RDS 自動備份，讓這次踩坑能在一小時內修復完成。\n","permalink":"https://peter-notes.com/posts/kubernetes-staging-cost-saving-lesson/","summary":"\u003ch2 id=\"起因老闆想省錢\"\u003e起因：老闆想省錢\u003c/h2\u003e\n\u003cp\u003e「Staging 環境平常沒人用，每個月還要燒 $45-60 美金，能不能想辦法省一點？」\u003c/p\u003e\n\u003cp\u003eStaging 環境的成本來自兩個地方：RDS 資料庫（約 $15-20/月）和 EKS 節點（約 $30-40/月）。既然平常沒在用，我想到了一個方案：\u003cstrong\u003e不用的時候關掉，需要的時候再打開\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e於是我寫了兩個腳本：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003estaging-start.sh\u003c/code\u003e：啟動 RDS、擴充節點、部署應用\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003estaging-stop.sh\u003c/code\u003e：刪除部署、縮減節點、停止 RDS\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# staging-stop.sh 核心邏輯\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ekubectl delete deployment app-strapi-stg app-web-stg\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eaws eks update-nodegroup-config \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e    --cluster-name my-cluster \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e    --nodegroup-name my-nodegroup \u003cspan class=\"se\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"se\"\u003e\u003c/span\u003e    --scaling-config \u003cspan class=\"nv\"\u003eminSize\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e0,maxSize\u003cspan class=\"o\"\u003e=\u003c/span\u003e2,desiredSize\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"m\"\u003e1\u003c/span\u003e  \u003cspan class=\"c1\"\u003e# 從 2 縮到 1\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eaws rds stop-db-instance --db-instance-identifier my-stg-rds\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e看起來很合理，但這裡有個問題：\u003cstrong\u003eProduction 和 Staging 共用同一個 nodegroup\u003c/strong\u003e。\u003c/p\u003e\n\u003ch2 id=\"踩坑aws-隨機選擇刪除節點\"\u003e踩坑：AWS 隨機選擇刪除節點\u003c/h2\u003e\n\u003cp\u003e執行 \u003ccode\u003estaging-stop.sh\u003c/code\u003e 縮減節點時，AWS 會\u003cstrong\u003e隨機選擇\u003c/strong\u003e要終止哪個節點。當時的配置：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e節點 A：運行 Production pods\u003c/li\u003e\n\u003cli\u003e節點 B：運行 Staging pods\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e我期望刪除節點 B，但 AWS 選了節點 A。Production pods 被強制遷移，觸發了重新調度。\u003c/p\u003e","title":"Kubernetes Staging 環境省錢術：從踩坑到正確實作"},{"content":"前言：同一個功能，截然不同的 Debug 體驗 最近在維護一個同時有 Vue 前端和 Flutter App 的專案。兩邊都要實作「關於我們」頁面的選單過濾邏輯——根據不同情境顯示或隱藏特定項目。\nVue 那邊：兩天內改了十幾個 commit，每次都是小幅調整，順順地完成。\nFlutter 這邊：卡了一整天，改了一個地方沒效果，懷疑方向錯誤，來回折騰。\n同樣的業務邏輯，為什麼 debug 體驗差這麼多？\n這篇文章從 debug 實戰出發，一路延伸到架構層面的反思。我們會探討 Domain Model 的防禦能力、Clean Architecture 的責任邊界、扁平架構的取捨、BFF 的可靠性價值，最後揭露這次 debug 困難的真正原因——交接代碼的信任陷阱。\nPart 1：Debug 實戰 Vue：問題在 UI 顯示層 Vue 那邊的典型修正長這樣：\n// Vue - 在 computed 裡加一個 filter const filteredPageTabs = computed(() =\u0026gt; { return response.value.pageTabs .filter(item =\u0026gt; item.subtitle !== \u0026#39;Service\u0026#39;) .map(item =\u0026gt; { if (item.subtitle === \u0026#39;ABOUT_US\u0026#39;) { return { ...item, subTabs: item.subTabs?.filter( subTab =\u0026gt; subTab.title !== \u0026#39;醫療團隊\u0026#39; ) || [] } } return item }) }) 問題本質：資料從 API 回來是正確且完整的，只需要決定「哪些要顯示在畫面上」。\nDebug 過程：打開 Vue DevTools → 看 store 資料 → 加個 filter → 完成。整個過程不超過 10 分鐘。\nFlutter：問題在資料取得層 Flutter 這邊的症狀是「有資料顯示，但內容不對」——所有 Tab 都顯示相同內容，而且未登入用戶有時看不到任何東西。\n這種「有東西但不對」的症狀最難 debug，因為你會傾向認為資料流是正確的，只是某個小細節有問題。\n追蹤下去，發現三個獨立的 bug：\nBug #1：GraphQL Client 選錯\n// 修正前：公開內容卻用了需要授權的 client final result = await _client.query(options); // 修正後：改用公開的 client final result = await _publicClient.query(options); _client 帶有 Authorization header，當用戶未登入時，某些情況下 API 會靜默返回空資料，而非拋出 403。\nBug #2：GraphQL Query 缺少必要欄位\n# 修正前：少了 id 欄位，無法過濾 query GetDoctors { nested_cs { groups { ... } } } # 修正後 query GetDoctors { nested_cs { id groups { ... } } } GraphQL 的陰險之處：漏掉欄位不會報錯。API 照樣回傳，只是少了那個欄位。\nBug #3：過濾邏輯根本沒寫\n// 修正前：直接取全部，忽略 pageId 參數 doctorSectionsByPageId[pageId] = layout.first.nested_cs; // 修正後：根據 pageId 過濾 final matchingNestedC = allNestedCs .where((nc) =\u0026gt; nc.id == pageId) .toList(); 為什麼 Flutter 難 Debug？ 面向 Vue Flutter 問題位置 UI 顯示層 資料取得層 資料正確性 ✅ 資料正確 ❌ 資料本身就錯 Bug 數量 1 個 3 個互相掩護 失敗模式 明顯（多顯示東西） 隱晦（有資料但不對） 最痛苦的是「多個 bug 互相掩護」：\nBug #1 (Client 錯) 單獨存在 → 未登入時無資料 Bug #2 (過濾錯) 單獨存在 → 所有 Tab 顯示相同 兩個疊加 → 有時有資料、有時沒有、內容又不對 當你修好 Bug #1，問題看起來依然存在。這會讓你懷疑自己的判斷，甚至走回頭路。\nPart 2：架構防禦——Domain Model 能攔住什麼？ 一個自然的問題浮現：如果強制 Repository 回傳「完整、已驗證的 Domain Model」，這三個 bug 哪些會被提早攔下來？\n分析結果 Bug 會被攔下來？ 原因 #1 Client 選錯 ❌ 不會 Repository 內部實作細節 #2 缺少 id 欄位 ⚠️ 看定義 required 會攔，nullable 會溜過 #3 過濾邏輯沒寫 ❌ 不會 業務邏輯問題，不是資料完整性問題 Bug #2 能被攔下的關鍵 // ✅ 這樣會攔下來 @freezed class NestedC with _$NestedC { factory NestedC({ required int id, // GraphQL 沒回傳就會 crash required List\u0026lt;Group\u0026gt; groups, }) = _NestedC; } // ❌ 這樣會溜過 @freezed class NestedC with _$NestedC { factory NestedC({ int? id, // null 也合法，bug 繼續潛伏 required List\u0026lt;Group\u0026gt; groups, }) = _NestedC; } 結論 「強制 Domain Model」只能解決資料完整性問題，無法解決：\n權限/環境問題（用錯 client） 業務邏輯問題（該過濾沒過濾） 不同類型的問題需要不同層級的防禦機制。\nPart 3：邊界思辨——Repository 該不該帶業務語意？ 既然 Bug #3 是「過濾邏輯沒寫」，有個誘人的解法：\n讓 Repository 直接回傳 Map\u0026lt;PageId, List\u0026lt;NestedC\u0026gt;\u0026gt;\n這樣 ViewModel 就不用自己過濾了。Bug #3 確實會被消滅。但代價是什麼？\nRepository 開始「理解」業務 原本清楚的分層： ┌─────────────┐ │ Repository │ → 取得 raw data，轉成 Domain Model ├─────────────┤ │ Use Case │ → 根據業務邏輯過濾、組合 ├─────────────┤ │ ViewModel │ → 組裝成 View 需要的格式 └─────────────┘ 讓 Repository 回傳 Map\u0026lt;PageId, ...\u0026gt; 後： ┌─────────────┐ │ Repository │ → 取得 + 根據 PageId 分組 ← 越界了 ├─────────────┤ │ Use Case │ → ...沒事做了？ ├─────────────┤ │ ViewModel │ → 直接用 └─────────────┘ Repository 本來只該知道「如何取得資料」。現在它開始知道「PageId 對應到哪些 NestedC」——這是業務規則，不是資料存取邏輯。\n為什麼這條線難守？ 因為「讓 Repository 多做一點」短期很方便：\n少寫一個 Use Case ViewModel 程式碼更簡潔 「反正這個過濾邏輯不會變」 但一旦開這個口：\n下次有人會說「讓 Repository 順便過濾 isActive 吧」 再下次「順便排序吧」 最後 Repository 變成什麼都做的 God Class 更乾淨的解法 // Use Case 層 — 業務邏輯應該在這裡 class GetDoctorsByPageIdUseCase { final DoctorRepository _repository; Future\u0026lt;List\u0026lt;NestedC\u0026gt;\u0026gt; execute(String pageId) async { final allDoctors = await _repository.fetchDoctors(); return allDoctors.where((nc) =\u0026gt; nc.id == pageId).toList(); } } 這條線，正好卡在 Clean Architecture 最容易失守的地方。\nPart 4：反面世界——如果採用扁平架構？ 到目前為止，我們都在討論多層架構的問題。但如果反過來思考：\n如果我們直接讓 ViewModel 呼叫 GraphQL，完全跳過 Repository / Use Case，用「畫面即邏輯」換取開發速度——這三個 bug 的命運會如何？\n原本：View → ViewModel → Repository → Service → API 扁平：View → ViewModel → GraphQL Client → API 對照表 Bug 多層架構 扁平架構 #1 Client 選錯 藏在 Service 層 可能更早爆（被迫選擇） #2 缺少 id 要追多層才發現 ✅ 更早爆（用和取距離近） #3 過濾沒寫 藏在層間縫隙 直接爆在 UI 上 扁平架構的諷刺 扁平架構的 bug 會「更早爆」，但爆的地方不一定是開發階段——因為沒有中間層的驗證，問題可能直接穿透到 View，讓用戶先看到。\n架構 問題特性 發現時機 多層 問題被隔離，但可能藏在縫隙 開發後期或 production 扁平 問題無處躲藏 開發時或直接爆在用戶面前 這就是「畫面即邏輯」的雙面刃：開發速度快，但缺乏緩衝層。\nPart 5：可靠性工程——BFF、ACL 與信任邊界 如果目標不是「降低 debug 成本」，而是「確保錯誤永遠不會直接影響使用者」——該怎麼設計？\n什麼是信任邊界？ 信任邊界（Trust Boundary） 是系統架構中的一條線，用來區分「我信任的」和「我不信任的」。\n┌─────────────────────────────────────────────┐ │ 我控制的、我信任的 │ │ ┌─────────┐ ┌─────────┐ │ │ │ 前端 │ │ BFF │ │ │ └─────────┘ └─────────┘ │ └─────────────────────────────────────────────┘ ║ ← 信任邊界 ║ （在這裡做驗證） ↓ ┌─────────────────────────────────────────────┐ │ 我不控制的、我不信任的 │ │ ┌─────────────────────────────────────┐ │ │ │ 第三方 API / 外部服務 │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────────┘ 過了信任邊界的資料，後面的程式碼可以「放心用」。\nBFF：為前端量身打造的後端 BFF（Backend for Frontend） 是一種架構模式：每一種前端（App / Web / Admin）都可以有一個「專屬後端」。\nBFF 解決什麼問題？ 後端 API 通常有以下特性：\n為「多種客戶」設計（App、Web、第三方） 結構偏通用，未必符合某個畫面的需求 回傳資料可能很多，但前端只用其中一小部分 結果是：\n前端要做大量轉換、過濾、補欄位 錯誤處理散落在各層 Domain Model 防線被一層層磨薄 BFF 的核心理念：讓前端只拿到「剛好能用」的資料結構。\n在這個案例中，BFF 會做什麼？ 引入 BFF 後： App → ViewModel → BFF → Backend ↑ 扁平便利 ↑ 集中防禦 BFF 職責 說明 統一授權 不再讓前端選 client 驗證必要欄位 缺 id 直接失敗 套用業務規則 依 PageId 分組 / 過濾 回傳可直接 render 的結構 前端不用再處理 前端不再需要：\n判斷資料是否完整 猜測是否該過濾 擔心後端 schema 偷偷變動 ACL：防止外部世界污染內部模型 ACL（Anti-Corruption Layer） 是 DDD（Domain-Driven Design）中的概念。它關心的不是「效能」或「方便」，而是：\n如何避免外部系統的混亂，滲透進我們的核心模型。\n為什麼需要 ACL？ 外部系統（包含後端）常見問題：\n欄位可有可無（nullable everywhere） 語意模糊（status = 1 是什麼？） 結構不穩定（今天在 A，明天搬到 B） 如果我們直接在 App Domain 使用這些資料：\nDomain Model 被迫變得寬鬆 required 變 optional 驗證邏輯散落各處 久了之後，整個 Domain 都被「外部世界的妥協」腐蝕。\nACL 的責任 ACL 站在「信任邊界」上，只做三件事：\n驗證：不符合內部期待的資料，直接拒絕 轉換：把外部模型轉成內部 Domain Model 隔離：外部 schema 變動，不影響內部 在這個案例中：\nGraphQL Response = 外部模型 App Domain Model = 內部秩序 BFF = ACL 的最佳落點 BFF 與 ACL 的關係 概念 關注點 在本案例的角色 BFF 系統邊界 前端的專屬後端 ACL 模型邊界 驗證與轉換層 BFF 是架構位置 ACL 是設計原則 你可以：\n在 BFF 裡實作 ACL（最常見、也最合理） 在 App 入口實作簡化版 ACL（當沒有 BFF 時） 這三個 Bug 在 BFF 架構下的命運 Bug 沒有 BFF 有 BFF #1 Client 選錯 藏在 Service 層 BFF 統一處理授權 #2 缺少 id 穿透到 ViewModel BFF 驗證失敗，回傳明確錯誤 #3 過濾沒寫 藏在層間縫隙 BFF 直接回傳過濾後的資料 Bug #2 和 #3 會被 BFF 完全消滅。\n為什麼 BFF 比多層前端架構更可靠？ 多層前端架構假設：\n每一層都會好好守住自己的責任\n但現實是：\n層與層之間容易出現「責任縫隙」 驗證邏輯被稀釋 Bug 剛好從縫隙穿過 BFF + ACL 的假設更務實：\n只有在信任邊界集中防禦，錯誤才不會碰到使用者。\n收斂一句話 Clean Architecture 解決的是「系統內部怎麼乾淨」 ACL 解決的是「外部世界多髒都沒關係」 BFF 解決的是「前端不該當垃圾處理場」 當你把這三件事放對位置，錯誤就不再需要靠運氣才能被擋下來。\nPart 6：真相——交接代碼的信任陷阱 到這裡，我們討論了很多架構層面的問題。但這次 debug 困難的真正原因，其實更實際：\nGraphQL Query 是外包商寫的，之前是正常的。\n重新審視信任邊界 來源 誰寫的 可信嗎？ Strapi API 我 ✅ 可信（Vue 用同樣的 API 沒問題） Vue 前端 我 ✅ 可信 Flutter GraphQL Query 外包商 ⚠️ 交接代碼 Flutter 業務邏輯 我在維護 ✅ 可信 Debug 困難的真正原因 我的心理模型： 「Query 之前是正常的 → Query 應該沒問題 → 問題在我的邏輯」 實際情況： 「Query 之前正常 → 但需求變了 / 被改過了 → Query 已經不符合現在的需求」 我一直在自己的邏輯裡找問題，但問題其實在「我以為可信」的外包商代碼裡。\n交接代碼的信任陷阱 陷阱 說明 「之前正常」的假設 你不知道它為什麼正常、在什麼條件下正常 沒有完整的測試覆蓋 外包商走了，測試也沒留下 需求變了但代碼沒跟著變 新功能需要 id，但沒人去改 Query 文件不完整 不知道每個欄位的用途 真正該畫的信任邊界 傳統觀念認為信任邊界在「後端 vs 前端」。但這次的案例告訴我們：\n信任邊界不只是「外部 vs 內部」， 也包括「交接代碼 vs 我的代碼」。 交接代碼 = 不可信，直到你親自驗證過。\n外包商寫的代碼（GraphQL Query、舊邏輯） ║ ════════════║════════════ ← 信任邊界 ║ 「不要假設它是對的」 ↓ 你維護的代碼 這次學到的教訓 「之前正常」不代表「現在正確」——尤其是交接來的代碼。\n當你接手別人的代碼時：\n不要假設它是對的，即使「之前正常」 建立你自己的驗證機制（Domain Model 的 required 欄位） 當 bug 找不到時，擴大懷疑範圍——包括你以為可信的部分 總結：六個層次的反思 這次的 debug 經驗，可以從六個層次來看：\n層次一：Debug 技巧 當症狀是「有資料但不對」，問題往往在資料取得層 多個 bug 可能互相掩護，修一個沒效果不代表方向錯 逐層加 log，找出資料在哪一層開始「不對勁」 層次二：架構防禦 Domain Model 的 required 欄位可以攔下資料完整性問題 但無法攔下權限問題和業務邏輯問題 不同類型的問題需要不同層級的防禦機制 層次三：責任邊界 Repository 只該負責「資料存取」 業務邏輯（過濾、分組）應該在 Use Case 層 「讓 Repository 多做一點」短期方便，長期會模糊邊界 層次四：架構取捨 多層架構：問題被隔離，但可能藏在縫隙 扁平架構：問題無處躲藏，但缺乏緩衝 沒有絕對的好壞，只有適不適合 層次五：可靠性工程 信任邊界 = 「在這裡之後，我就信任資料是對的」的那條線 BFF 可以把「扁平的便利」和「多層的防禦」拆成兩個世界 目標不是「減少 bug」，而是「確保 bug 不會影響用戶」 層次六：交接代碼 交接代碼是另一種「不可信」的來源 「之前正常」不代表「現在正確」 當 bug 找不到時，擴大懷疑範圍——包括你以為可信的部分 結語 同樣的業務邏輯，Vue 和 Flutter 的 debug 難度差了一個數量級。\n表面原因是 bug 的數量和位置不同。 深層原因是架構的複雜度和問題的本質不同。 真正原因是我信任了不該信任的代碼。\n這次的三個 bug——Client 選錯、欄位遺漏、過濾沒寫——分別發生在不同層級。它們能夠互相掩護、難以追蹤，是因為：\n沒有一個「信任邊界」在統一把關 我假設外包商的 Query 是對的 我在自己的邏輯裡繞圈子，沒有擴大懷疑範圍 架構的價值不在於它有多少層，而在於它能不能讓問題變簡單。\n但更重要的是：你要知道哪些代碼可以信任，哪些不行。\n畫對那條線，問題會被攔在線外。 畫錯那條線，問題會藏在你以為安全的地方。\n有時候，你需要修好所有的 bug，問題才會消失。 有時候，你需要畫對那條線，bug 才不會出現。 有時候，你需要承認：「之前正常」的代碼，現在可能是問題的根源。\n","permalink":"https://peter-notes.com/posts/flutter-vs-vue-debugging-complexity/","summary":"\u003ch2 id=\"前言同一個功能截然不同的-debug-體驗\"\u003e前言：同一個功能，截然不同的 Debug 體驗\u003c/h2\u003e\n\u003cp\u003e最近在維護一個同時有 Vue 前端和 Flutter App 的專案。兩邊都要實作「關於我們」頁面的選單過濾邏輯——根據不同情境顯示或隱藏特定項目。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eVue 那邊\u003c/strong\u003e：兩天內改了十幾個 commit，每次都是小幅調整，順順地完成。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eFlutter 這邊\u003c/strong\u003e：卡了一整天，改了一個地方沒效果，懷疑方向錯誤，來回折騰。\u003c/p\u003e\n\u003cp\u003e同樣的業務邏輯，為什麼 debug 體驗差這麼多？\u003c/p\u003e\n\u003cp\u003e這篇文章從 debug 實戰出發，一路延伸到架構層面的反思。我們會探討 Domain Model 的防禦能力、Clean Architecture 的責任邊界、扁平架構的取捨、BFF 的可靠性價值，最後揭露這次 debug 困難的真正原因——交接代碼的信任陷阱。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"part-1debug-實戰\"\u003ePart 1：Debug 實戰\u003c/h2\u003e\n\u003ch3 id=\"vue問題在-ui-顯示層\"\u003eVue：問題在 UI 顯示層\u003c/h3\u003e\n\u003cp\u003eVue 那邊的典型修正長這樣：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// Vue - 在 computed 裡加一個 filter\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"kr\"\u003econst\u003c/span\u003e \u003cspan class=\"nx\"\u003efilteredPageTabs\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nx\"\u003ecomputed\u003c/span\u003e\u003cspan class=\"p\"\u003e(()\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"nx\"\u003eresponse\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003evalue\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003epageTabs\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003efilter\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eitem\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"nx\"\u003eitem\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003esubtitle\u003c/span\u003e \u003cspan class=\"o\"\u003e!==\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;Service\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003emap\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eitem\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eitem\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003esubtitle\u003c/span\u003e \u003cspan class=\"o\"\u003e===\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;ABOUT_US\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e          \u003cspan class=\"p\"\u003e...\u003c/span\u003e\u003cspan class=\"nx\"\u003eitem\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e          \u003cspan class=\"nx\"\u003esubTabs\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"nx\"\u003eitem\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003esubTabs\u003c/span\u003e\u003cspan class=\"o\"\u003e?\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003efilter\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e            \u003cspan class=\"nx\"\u003esubTab\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"nx\"\u003esubTab\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003etitle\u003c/span\u003e \u003cspan class=\"o\"\u003e!==\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;醫療團隊\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e          \u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"p\"\u003e[]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"nx\"\u003eitem\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e})\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e})\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e問題本質\u003c/strong\u003e：資料從 API 回來是正確且完整的，只需要決定「哪些要顯示在畫面上」。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eDebug 過程\u003c/strong\u003e：打開 Vue DevTools → 看 store 資料 → 加個 filter → 完成。整個過程不超過 10 分鐘。\u003c/p\u003e","title":"同樣的功能，為什麼 Flutter 比 Vue 難 Debug 十倍？從實戰到架構的六層反思"},{"content":" 「奇怪，我明明刪了 52 萬筆資料，為什麼資料表還是 207MB？」\n這是我今天在清理資料庫時遇到的真實情況。如果你也曾經困惑過這個問題，這篇文章會告訴你背後的原因。\n事情是這樣的 專案的 user_notifications 資料表累積了幾十萬筆推播通知記錄，佔用了 207MB 空間。為了控制資料庫大小，我寫了一個 cron job 來清理超過 7 天的舊資料：\n// 刪除 7 天前的通知 const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); await strapi.db.query(\u0026#39;api::user-notification.user-notification\u0026#39;).deleteMany({ where: { createdAt: { $lt: sevenDaysAgo.toISOString() } }, }); 執行結果很漂亮：\n[Cleanup] Successfully deleted 521604 old user notifications 刪除了 521,604 筆！只剩下約 2 萬筆近期資料。\n但當我打開 DBeaver 檢查時\u0026hellip;\n207MB？資料都刪了，空間怎麼沒變？\n為什麼會這樣？理解 PostgreSQL 的 MVCC 這不是 bug，而是 PostgreSQL 的設計特性。\nDELETE 不是真的刪除 PostgreSQL 使用 MVCC（Multi-Version Concurrency Control） 來處理並發交易。當你執行 DELETE 時，PostgreSQL 不會真的把資料從磁碟上移除，而是：\n將該行標記為「已刪除」（稱為 dead tuple） 保留原始資料，直到沒有任何交易需要參照它 新的查詢看不到這些行，但它們仍佔用磁碟空間 為什麼要這樣設計？\n效能考量：標記刪除比實際移除資料快非常多 並發安全：其他正在執行的 transaction 可能還需要看到舊版本 ACID 保證：確保 transaction isolation 不處理會怎樣？\n磁碟空間不會釋放，資料庫持續膨脹 查詢效能逐漸下降（需要跳過大量 dead tuples） Index 也會膨脹，進一步影響效能 用圖來理解整個流程 這張圖說明了為什麼 DELETE 後空間不會立即釋放——資料只是變成 dead tuple，需要 VACUUM 才能處理。\nSQLite 呢？比較兩者的差異 你可能會想：「SQLite 也是這樣嗎？」\n答案是：類似但不完全相同。\n特性 PostgreSQL SQLite DELETE 行為 標記為 dead tuple (MVCC) 標記頁面為 free 空間處理 需要 VACUUM 清理 需要 VACUUM 釋放 並發控制 MVCC（多版本並存） 檔案級鎖定 VACUUM 方式 原地清理或完整重寫 完整重寫檔案 自動清理 autovacuum 背景執行 需手動或設定 auto_vacuum SQLite 的情況：\n-- SQLite 刪除後，空間也不會立即釋放 DELETE FROM notifications WHERE created_at \u0026lt; \u0026#39;2025-12-24\u0026#39;; -- 查看資料庫檔案大小 - 沒變！ -- 需要執行 VACUUM VACUUM; -- 現在檔案才會縮小 SQLite 的 VACUUM 會重寫整個資料庫檔案，這在小型資料庫很快，但大型資料庫會很慢且需要額外空間。\n關鍵差異： PostgreSQL 的 MVCC 允許高並發讀寫，代價是需要 VACUUM 清理；SQLite 使用檔案鎖，較簡單但並發能力有限。\n解決方案：VACUUM PostgreSQL 提供 VACUUM 指令來清理 dead tuples。但這裡有個重要區別：\nVACUUM vs VACUUM FULL 特性 VACUUM VACUUM FULL 鎖表 ❌ 不鎖 ✅ 會鎖（獨佔鎖） 空間處理 標記為可重用 實際釋放回 OS 執行速度 快 慢 生產環境 ✅ 安全 ⚠️ 離峰時段 普通 VACUUM 不會立即縮小檔案大小，但會將空間標記為「可重用」，下次 INSERT 時會優先使用這些空間。\nVACUUM FULL 會重寫整個資料表，真正釋放磁碟空間，但代價是獨佔鎖定整張表——在鎖定期間，任何讀寫操作都會被阻塞。\n實際執行 VACUUM FULL user_notifications; 結果：\n207MB → 8.1MB，空間終於釋放了。\n實務建議：整合到自動化流程 知道問題後，最好的做法是將 VACUUM 整合到清理流程中：\n// cron job: 每天凌晨 3 點執行 cleanupOldUserNotifications: { task: async ({ strapi }) =\u0026gt; { // 1. 批次刪除舊資料 let totalDeleted = 0; while (true) { const old = await strapi.db.query(\u0026#39;...\u0026#39;).findMany({ limit: 10000 }); if (old.length === 0) break; await strapi.db.query(\u0026#39;...\u0026#39;).deleteMany({ where: { id: { $in: ids } } }); totalDeleted += old.length; } // 2. 刪除後執行 VACUUM FULL（凌晨低流量時段，鎖表可接受） if (totalDeleted \u0026gt; 0) { await strapi.db.connection.raw(\u0026#39;VACUUM FULL user_notifications\u0026#39;); } }, options: { rule: \u0026#39;0 0 3 * * *\u0026#39;, // 每天凌晨 3 點 tz: \u0026#39;Asia/Taipei\u0026#39;, }, } 這裡選擇 VACUUM FULL 而非普通 VACUUM，因為：\n凌晨 3 點流量極低，短暫鎖表影響有限 真正釋放空間回 OS，而非只標記可重用 同時重建 index，避免 index bloat 問題 對於 user_notifications 這種高流量刪除的表，定期徹底清理更有效 如果你的服務 24 小時都有流量，可以改用普通 VACUUM（不鎖表但不釋放空間）。\n未來會踩的坑 1. autovacuum 不是萬能的 PostgreSQL 有 autovacuum 機制會自動清理 dead tuples，但它的觸發條件是基於「異動比例」。大量刪除後，autovacuum 可能不會立即啟動，或者清理速度跟不上你的刪除速度。\n2. VACUUM FULL 的隱藏成本 除了鎖表之外，VACUUM FULL 還需要額外的磁碟空間來建立新版本的資料表。如果你的磁碟已經快滿了，執行 VACUUM FULL 可能會失敗。\n3. Index 也需要清理 普通 VACUUM 不會處理 index bloat。 VACUUM 只清理 table（heap）的 dead tuples，index 的空頁會被標記為可重用，但檔案大小不會縮小。\n操作 Table 處理 Index 處理 VACUUM ✅ 清理 dead tuples ❌ 不重建，只回收空頁 VACUUM FULL ✅ 重寫並釋放空間 ✅ 同時重建所有 index REINDEX ❌ 不處理 ✅ 專門重建 index 如果你發現查詢變慢但 table 已經 VACUUM 過，可能是 index bloat 的問題：\n-- 重建特定 index（會鎖定該 index） REINDEX INDEX idx_user_notifications_created_at; -- PostgreSQL 12+ 非阻塞版本（推薦生產環境） REINDEX INDEX CONCURRENTLY idx_user_notifications_created_at; -- 重建整張表的所有 index REINDEX TABLE user_notifications; 注意：REINDEX CONCURRENTLY 雖然不阻塞讀寫，但會消耗更多資源且執行時間更長。\n這是基礎知識嗎？ 對 DBA 來說，是的。\n但對大多數 Fullstack 開發者來說，這是「踩過坑才會知道」的經驗。畢竟，誰會預期 DELETE 之後空間不會自動釋放呢？\n今天學到的教訓：PostgreSQL 的 DELETE 只是標記刪除，真正的清理需要 VACUUM。\n延伸閱讀 PostgreSQL: VACUUM PostgreSQL: Routine Vacuuming SQLite: The VACUUM command ","permalink":"https://peter-notes.com/posts/postgresql-vacuum-disk-space/","summary":"\u003cblockquote\u003e\n\u003cp\u003e「奇怪，我明明刪了 52 萬筆資料，為什麼資料表還是 207MB？」\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e這是我今天在清理資料庫時遇到的真實情況。如果你也曾經困惑過這個問題，這篇文章會告訴你背後的原因。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"事情是這樣的\"\u003e事情是這樣的\u003c/h2\u003e\n\u003cp\u003e專案的 \u003ccode\u003euser_notifications\u003c/code\u003e 資料表累積了幾十萬筆推播通知記錄，佔用了 207MB 空間。為了控制資料庫大小，我寫了一個 cron job 來清理超過 7 天的舊資料：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// 刪除 7 天前的通知\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"kr\"\u003econst\u003c/span\u003e \u003cspan class=\"nx\"\u003esevenDaysAgo\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"k\"\u003enew\u003c/span\u003e \u003cspan class=\"nb\"\u003eDate\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nb\"\u003eDate\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003enow\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e \u003cspan class=\"mi\"\u003e7\u003c/span\u003e \u003cspan class=\"o\"\u003e*\u003c/span\u003e \u003cspan class=\"mi\"\u003e24\u003c/span\u003e \u003cspan class=\"o\"\u003e*\u003c/span\u003e \u003cspan class=\"mi\"\u003e60\u003c/span\u003e \u003cspan class=\"o\"\u003e*\u003c/span\u003e \u003cspan class=\"mi\"\u003e60\u003c/span\u003e \u003cspan class=\"o\"\u003e*\u003c/span\u003e \u003cspan class=\"mi\"\u003e1000\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"nx\"\u003estrapi\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003edb\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003equery\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;api::user-notification.user-notification\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e).\u003c/span\u003e\u003cspan class=\"nx\"\u003edeleteMany\u003c/span\u003e\u003cspan class=\"p\"\u003e({\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"nx\"\u003ewhere\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nx\"\u003ecreatedAt\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nx\"\u003e$lt\u003c/span\u003e: \u003cspan class=\"kt\"\u003esevenDaysAgo.toISOString\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"p\"\u003e},\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e});\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e執行結果很漂亮：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"o\"\u003e[\u003c/span\u003eCleanup\u003cspan class=\"o\"\u003e]\u003c/span\u003e Successfully deleted \u003cspan class=\"m\"\u003e521604\u003c/span\u003e old user notifications\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e刪除了 521,604 筆！只剩下約 2 萬筆近期資料。\u003c/p\u003e\n\u003cp\u003e但當我打開 DBeaver 檢查時\u0026hellip;\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"VACUUM 前：資料表仍然是 207MB\" loading=\"lazy\" src=\"/images/postgresql-vacuum/before-vacuum.jpg\"\u003e\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e207MB？資料都刪了，空間怎麼沒變？\u003c/strong\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"為什麼會這樣理解-postgresql-的-mvcc\"\u003e為什麼會這樣？理解 PostgreSQL 的 MVCC\u003c/h2\u003e\n\u003cp\u003e這不是 bug，而是 PostgreSQL 的設計特性。\u003c/p\u003e\n\u003ch3 id=\"delete-不是真的刪除\"\u003eDELETE 不是真的刪除\u003c/h3\u003e\n\u003cp\u003ePostgreSQL 使用 \u003cstrong\u003eMVCC（Multi-Version Concurrency Control）\u003c/strong\u003e 來處理並發交易。當你執行 \u003ccode\u003eDELETE\u003c/code\u003e 時，PostgreSQL 不會真的把資料從磁碟上移除，而是：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e將該行標記為「已刪除」（稱為 \u003cstrong\u003edead tuple\u003c/strong\u003e）\u003c/li\u003e\n\u003cli\u003e保留原始資料，直到沒有任何交易需要參照它\u003c/li\u003e\n\u003cli\u003e新的查詢看不到這些行，但它們仍佔用磁碟空間\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e\u003cstrong\u003e為什麼要這樣設計？\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e效能考量\u003c/strong\u003e：標記刪除比實際移除資料快非常多\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e並發安全\u003c/strong\u003e：其他正在執行的 transaction 可能還需要看到舊版本\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eACID 保證\u003c/strong\u003e：確保 transaction isolation\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e不處理會怎樣？\u003c/strong\u003e\u003c/p\u003e","title":"刪了 52 萬筆資料，為什麼硬碟空間沒變小？"},{"content":"前言：當 Banner 在眾目睽睽下消失 如果你曾經盯著一個「昨天還好好的」功能，然後花了幾小時才發現問題根本不在你想的地方——恭喜你，你已經正式成為資深工程師了。\n這次的主角是一個 Flutter App 的首頁輪播 Banner。用戶回報說「Banner 不見了」，而我的第一反應是：「一定是最近的 commit 搞壞的！」\n（劇透：不是。）\n第一階段：追蹤嫌疑犯 嫌疑人一號：最近的 Git Commit 最近剛好有一個 commit 修改了後端的 middleware，用來處理認證相關的端點。自然而然，我先從這裡開始查：\ngit show abc123 # fix: add middleware to strip auth header for public endpoints 看起來這個 middleware 只處理 /api/auth/forgot-password 這類認證端點，跟 Banner API 完全無關。\n結論：無罪釋放。\n嫌疑人二號：Strapi 權限設定 接下來檢查 Strapi Admin 的權限設定。Public 角色的 app-home-page 權限：\n角色 find 權限 Authenticated ✅ 已開啟 Public ✅ 已開啟 權限設定完全正確。\n結論：也不是兇手。\n第二階段：真相大白 測試 REST API 直接用 curl 測試 REST API：\ncurl \u0026#34;https://api.example.com/api/app-home-page?populate=banners.image\u0026#34; # 回傳 200 OK，資料完整 REST API 完全正常！那問題出在哪？\n發現關鍵線索：Flutter 用的是 GraphQL 仔細檢查 Flutter App 的程式碼後，發現它呼叫的是 GraphQL 端點，不是 REST API：\nclass GraphQLService { GraphQLService() { final httpLink = HttpLink( AppConfig.gqlUrl, defaultHeaders: { \u0026#39;Authorization\u0026#39;: \u0026#39;Bearer ${dotenv.env[\u0026#39;API_TOKEN\u0026#39;]}\u0026#39;, // 靜態 API Token }, ); _client = GraphQLClient(link: httpLink, cache: GraphQLCache()); } } 原來 Flutter App 使用了一個靜態的 API Token 來呼叫所有 GraphQL 查詢。\n驗證假設 測試這個 API Token 是否有效：\n# 帶 API Token curl -X POST \u0026#34;https://api.example.com/graphql\u0026#34; \\ -H \u0026#34;Authorization: Bearer sk-invalid-token-12345...\u0026#34; \\ -d \u0026#39;{\u0026#34;query\u0026#34;:\u0026#34;query { appHomePage { banners { title } } }\u0026#34;}\u0026#39; # 回傳 401 Unauthorized {\u0026#34;error\u0026#34;:{\u0026#34;status\u0026#34;:401,\u0026#34;message\u0026#34;:\u0026#34;Missing or invalid credentials\u0026#34;}} # 不帶 Token curl -X POST \u0026#34;https://api.example.com/graphql\u0026#34; \\ -d \u0026#39;{\u0026#34;query\u0026#34;:\u0026#34;query { appHomePage { banners { title } } }\u0026#34;}\u0026#39; # 回傳 200 OK，資料完整 {\u0026#34;data\u0026#34;:{\u0026#34;appHomePage\u0026#34;:{\u0026#34;banners\u0026#34;:[...]}}} 破案了！ API Token 是無效的（可能過期或從未正確設定），導致 GraphQL 查詢回傳 401 錯誤。\n關鍵洞察：帶無效憑證比不帶憑證更糟糕 這裡有一個反直覺的事實：\n情境 結果 不帶 Authorization header ✅ 以 Public 角色通過 帶有效 JWT Token ✅ 以 Authenticated 角色通過 帶無效 Token ❌ 401 Unauthorized 用流程圖來看更清楚：\n當你帶著一張過期的會員卡進入公共圖書館時，管理員看到過期的卡，直接把你擋在門外——連公開區域都不讓你進。但如果你什麼卡都不帶，反而可以進去使用公開資源。\n解決方案：精準的後端 Middleware 為什麼不直接更新 App 的 Token？ 因為這是一個已上架的 App，更新程式碼需要經過 App Store 審核（1-3 天）。我們需要一個不用送審的解決方案。\n實作精準攔截 在 Strapi 後端新增 middleware，只移除那個特定的無效 Token：\n// src/middlewares/strip-invalid-token.ts // 特定的無效 API Token（來自 Flutter App 的 .env） const INVALID_FLUTTER_TOKEN = \u0026#39;sk-invalid-token-12345...\u0026#39;; export default (config, { strapi }) =\u0026gt; { return async (ctx, next) =\u0026gt; { if (ctx.request.header.authorization) { const token = ctx.request.header.authorization .replace(\u0026#39;Bearer \u0026#39;, \u0026#39;\u0026#39;) .trim(); // 只移除這個特定的無效 Token // 其他有效的 Token（Vue.js、用戶 JWT）都保留 if (token === INVALID_FLUTTER_TOKEN) { strapi.log.debug(`Removing invalid Flutter token for: ${ctx.request.path}`); delete ctx.request.header.authorization; delete ctx.request.headers.authorization; } } await next(); }; }; 為什麼要這麼精準？ 在調查過程中，我發現同一個後端服務被多個前端使用：\n前端 Token 類型 狀態 Flutter App 靜態 API Token ❌ 無效 Vue.js Web 靜態 API Token ✅ 有效 已登入用戶 JWT Token ✅ 有效 如果我們移除「所有非 JWT 格式的 Token」，會把 Vue.js 的有效 Token 也一起移除，導致網頁版壞掉。\n所以必須精準鎖定那個特定的無效 Token。\n長期解決方案 後端的 middleware 是臨時修復。真正的解決方案是修改 Flutter App：\n// 新增一個不帶 Authorization header 的公開 Client final publicHttpLink = HttpLink(AppConfig.gqlUrl); _publicClient = GraphQLClient(link: publicHttpLink, cache: GraphQLCache()); // Banner 查詢使用公開 Client Future\u0026lt;List\u0026lt;BannerData\u0026gt;\u0026gt; fetchBannerList() async { final options = QueryOptions(document: gql(Queries.bannerFetch)); // 使用不帶 Token 的 Client final result = await _publicClient.query(options); // ... } 這個修改已經準備好了，等下次 App 更新時一起送審。\n結論：Debug 的三個教訓 不要假設：最近的 commit 不一定是兇手，要用證據說話 理解認證機制：帶無效憑證比不帶憑證更糟糕 了解你的系統：同一個後端可能被多個前端使用，修改時要考慮全局影響 下次遇到類似問題時，記住：真正的 bug 往往藏在你最不懷疑的地方。\n","permalink":"https://peter-notes.com/posts/flutter-banner-401-invalid-token-debugging/","summary":"\u003ch2 id=\"前言當-banner-在眾目睽睽下消失\"\u003e前言：當 Banner 在眾目睽睽下消失\u003c/h2\u003e\n\u003cp\u003e如果你曾經盯著一個「昨天還好好的」功能，然後花了幾小時才發現問題根本不在你想的地方——恭喜你，你已經正式成為資深工程師了。\u003c/p\u003e\n\u003cp\u003e這次的主角是一個 Flutter App 的首頁輪播 Banner。用戶回報說「Banner 不見了」，而我的第一反應是：「一定是最近的 commit 搞壞的！」\u003c/p\u003e\n\u003cp\u003e（劇透：不是。）\u003c/p\u003e\n\u003ch2 id=\"第一階段追蹤嫌疑犯\"\u003e第一階段：追蹤嫌疑犯\u003c/h2\u003e\n\u003ch3 id=\"嫌疑人一號最近的-git-commit\"\u003e嫌疑人一號：最近的 Git Commit\u003c/h3\u003e\n\u003cp\u003e最近剛好有一個 commit 修改了後端的 middleware，用來處理認證相關的端點。自然而然，我先從這裡開始查：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003egit show abc123\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# fix: add middleware to strip auth header for public endpoints\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e看起來這個 middleware 只處理 \u003ccode\u003e/api/auth/forgot-password\u003c/code\u003e 這類認證端點，跟 Banner API 完全無關。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e結論：無罪釋放。\u003c/strong\u003e\u003c/p\u003e\n\u003ch3 id=\"嫌疑人二號strapi-權限設定\"\u003e嫌疑人二號：Strapi 權限設定\u003c/h3\u003e\n\u003cp\u003e接下來檢查 Strapi Admin 的權限設定。Public 角色的 \u003ccode\u003eapp-home-page\u003c/code\u003e 權限：\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e角色\u003c/th\u003e\n          \u003cth\u003efind 權限\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eAuthenticated\u003c/td\u003e\n          \u003ctd\u003e✅ 已開啟\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003ePublic\u003c/td\u003e\n          \u003ctd\u003e✅ 已開啟\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e權限設定完全正確。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e結論：也不是兇手。\u003c/strong\u003e\u003c/p\u003e\n\u003ch2 id=\"第二階段真相大白\"\u003e第二階段：真相大白\u003c/h2\u003e\n\u003ch3 id=\"測試-rest-api\"\u003e測試 REST API\u003c/h3\u003e\n\u003cp\u003e直接用 curl 測試 REST API：\u003c/p\u003e","title":"Flutter App 首頁 Banner 消失之謎：一場無效 API Token 的偵探之旅"},{"content":"商品資訊 品牌：KOLIN 歌林 型號：KBO-SD3008 容量：20 公升 狀態：全新未使用 售價：$700 商品規格 詳細規格可參考 蝦皮商品頁面\n交易方式 雙北、桃園可面交 其他地區可郵寄（運費另計） 商品照片 聯絡方式 有興趣請透過下方留言聯繫！\n","permalink":"https://peter-notes.com/life/2025-12-29-kolin-20l-electric-oven/","summary":"\u003ch2 id=\"商品資訊\"\u003e商品資訊\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e品牌\u003c/strong\u003e：KOLIN 歌林\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e型號\u003c/strong\u003e：KBO-SD3008\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e容量\u003c/strong\u003e：20 公升\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e狀態\u003c/strong\u003e：全新未使用\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e售價\u003c/strong\u003e：$700\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"商品規格\"\u003e商品規格\u003c/h2\u003e\n\u003cp\u003e詳細規格可參考 \u003ca href=\"https://shopee.tw/product/2789819/52801489955\"\u003e蝦皮商品頁面\u003c/a\u003e\u003c/p\u003e\n\u003ch2 id=\"交易方式\"\u003e交易方式\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e雙北、桃園可面交\u003c/li\u003e\n\u003cli\u003e其他地區可郵寄（運費另計）\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"商品照片\"\u003e商品照片\u003c/h2\u003e\n\u003cp\u003e\u003cimg alt=\"外箱\" loading=\"lazy\" src=\"/images/life/kolin-oven-1.webp\"\u003e\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"電烤箱正面\" loading=\"lazy\" src=\"/images/life/kolin-oven-2.webp\"\u003e\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"電烤箱側面\" loading=\"lazy\" src=\"/images/life/kolin-oven-3.webp\"\u003e\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"電烤箱內部\" loading=\"lazy\" src=\"/images/life/kolin-oven-4.webp\"\u003e\u003c/p\u003e\n\u003ch2 id=\"聯絡方式\"\u003e聯絡方式\u003c/h2\u003e\n\u003cp\u003e有興趣請透過下方留言聯繫！\u003c/p\u003e","title":"全新品未使用KOLIN 歌林20公升電烤箱 KBO-SD3008 LG PANASONIC HITACHI 國際牌最低價 $700"},{"content":"引言：一個技術選型的起點 2024 年 6 月，我坐在會議室裡，面對著老闆和行銷總監，準備報告我對公司新系統 CMS 的技術選型建議。這不是一個輕鬆的決定——選錯了，可能浪費數百萬的開發成本；選對了，能為公司省下可觀的人力支出。\n經過數週的研究與分析，作為一個架構規劃師同時也是技術決策者，我最終選擇了 Strapi 作為我們的 Headless CMS 解決方案。這篇文章將分享我的決策過程、考量因素，以及最重要的——這個選擇如何為公司省下大筆預算。\n什麼是 Headless CMS？ 在深入 Strapi 之前，先理解 Headless CMS 的核心概念。\n傳統 CMS vs Headless CMS 傳統 CMS 將前端與後端緊密耦合，網站的外觀和內容管理綁在一起。而 Headless CMS 則專注於內容管理和 API 提供，讓前端團隊可以使用任何技術框架來消費這些 API。\nHeadless CMS 的核心優勢 特性 傳統 CMS Headless CMS 前後端耦合 緊密耦合 完全分離 前端技術選擇 受限於 CMS 模板 任意框架（Vue、React、Flutter） 多平台支援 僅限網頁 Web、App、IoT 皆可 擴展性 中等 極高 API 優先 否 是 為什麼選擇 Strapi？三大核心原因 原因一：前後端完全分離 Strapi 作為 Headless CMS，天生就是為了前後端分離而設計。這意味著：\n實際效益：\n前端團隊可以使用最熟悉的框架（我選擇 Vue.js） 後端 API 可以同時服務網站和未來的 App 前後端可以獨立部署、獨立擴展 開發進度不會互相卡住 原因二：自動生成 API 和 Schema 這是我選擇 Strapi 最重要的原因——Content-Type Builder 功能讓我們可以在 Admin Panel 中直接設計資料結構，Strapi 會自動生成對應的 REST API 和 GraphQL API。\n傳統做法 vs Strapi 做法：\n這代表什麼？ 原本需要後端工程師花 2-3 天完成的 CRUD API，現在只需要 10 分鐘在 Admin Panel 點一點就完成了。\n原因三：開源且可自架 Strapi 是 MIT License 的開源專案，這意味著：\n零授權費用：不像 Contentful 或 Sanity 需要按用量付費 完全掌控：程式碼在自己手上，可以自由修改 資料主權：資料存在自己的伺服器，不用擔心供應商鎖定 技術選型過程：為什麼不選 Wagtail？ 在選擇 Strapi 之前，我也認真評估過另一個強大的 Headless CMS——Wagtail。\nWagtail 的發現過程 當時公司的電子商城服務商是 Shopline，我很好奇他們後台用的是什麼技術。經過一番調查：\n我自己把Wagtail在本地架起來，做一個prototype試了幾個功能，確認 Wagtail 確實是一個被大型電商平台採用的成熟方案。\nWagtail vs Strapi 比較 項目 Wagtail Strapi 程式語言 Python (Django) Node.js (JavaScript) 成熟度 非常成熟 成熟 Admin UI 優秀、友善 中等 台灣開發者生態 較少 較多 外包報價 偏高 合理 為什麼最終沒選 Wagtail？ 一個字：人。\n當時在台北尋找外包商時，我發現：\nPython 後端團隊非常稀少：有能力做 Django/Wagtail 的外包商屈指可數 報價明顯偏高：大概比 Node.js 團隊貴 30-50% 時程無法配合：稀缺的團隊案子排滿，無法配合我們的上線時間 相比之下，熟悉 Node.js 的開發團隊在台北隨處可見，報價合理，選擇性也多。\n結論：技術再好，找不到人做也是白搭。\nStrapi 的缺點：必須面對的現實 任何技術選型都必須誠實面對缺點，Strapi 也不例外。以下是我們實際使用後遇到的挑戰。\n缺點一：Admin Panel 學習門檻因人而異 Strapi 的 Admin Panel 對於平常就有在操作商業軟體的人來說，其實上手不難，熟能生巧。但對於那些本身對辦公室商業軟體就沒什麼 sense 的人，就會覺得 Strapi 很難用。\n重點不是 Strapi 難不難，而是使用者的軟體素養。\n缺點二：高度客製化需要額外 UI 層 如果你的後台需要非常客製化的 UI/UX，例如複雜的 Dashboard、特殊的審核流程、或品牌化的介面設計，就需要額外建立一層客製化的管理介面，再透過 API 串接 Strapi 的資料。\n額外 UI 層的成本分析(略估) 項目 預估工時 預估成本 需求分析與 UI 設計 40 小時 40,000 TWD 前端開發（Vue.js/React） 120 小時 120,000 TWD API 整合與測試 40 小時 40,000 TWD 後續維護（每月） 16 小時 16,000 TWD/月 一次性開發總成本 - 200,000 TWD 這筆「隱藏成本」必須在決策時納入考量。\n成本效益分析：完整版 現在讓我們把所有成本都算進去，做一個更完整的分析。\n人力成本計算 根據 2024 年台灣市場行情，資深後端工程師的月薪大約落在 7 萬到 9 萬 之間。\n三年總成本比較 項目 傳統自建方案 Strapi + 客製 UI 後端人力（36 個月） 16 萬 × 36 = 576 萬 0 全端/前端人力（36 個月） 6 萬 × 36 = 216 萬 12 萬 × 36 = 432 萬 客製化 UI 開發（一次性） 0 20 萬 客製化 UI 維護（36 個月） 0 1.6 萬 × 36 = 57.6 萬 伺服器費用（36 個月） 1 萬 × 36 = 36 萬 0.8 萬 × 36 = 28.8 萬 三年總成本 828 萬 538.4 萬 節省金額 - 289.6 萬 即使加上客製化 UI 的成本，三年下來仍可省下約 290 萬！\n決策時的風險評估 任何技術選型都有風險，以下是我當時的考量：\n風險與對策 風險 可能性 影響 對策 Strapi 專案停止維護 低 高 開源社群活躍，可 Fork 自維護 效能瓶頸 中 中 可透過快取和資料庫優化解決 功能不足需客製 高 低 Strapi 支援 Plugin 和自訂 API 團隊學習曲線 中 低 官方文件完善，社群資源豐富 Admin UI 不符需求 高 中 預先規劃客製化 UI 預算 為什麼不選其他方案？ 方案 排除原因 Wagtail (Python) 台北 Python 團隊稀少、報價過高 WordPress + REST API 效能差、架構老舊、安全性疑慮 Contentful SaaS 收費高，資料不在自己手上 Sanity 同上，且台灣用戶較少 自建 Node.js API 開發時間長，需要更多人力 給決策者的建議 根據我的經驗，選擇 Strapi 的適用情境：\n✅ 適合使用 Strapi 的情況 有1個資深工程師或工程團隊可以處理技術問題 使用者有基本的商業軟體操作經驗 預算有限但需要快速上線 需要 API-First 的架構 未來可能需要支援 App ⚠️ 需要額外考量的情況 使用者完全沒有軟體操作經驗 需要高度客製化的後台介面 對 UI/UX 有嚴格要求 ❌ 不建議使用 Strapi 的情況 完全沒有技術人員 需要開箱即用的完整方案 預算極度有限，無法負擔客製化 UI 結論：技術選型是一門藝術 回顧這次的技術選型，我學到幾個重要的經驗：\n技術選型不只是技術問題：更是商業決策，必須考慮成本、時間、人力 沒有完美的解決方案：只有最適合當前情境的選擇 量化效益很重要：用數字說服老闆比用技術術語有效得多 人才市場是關鍵：再好的技術，找不到人做也是白搭（Wagtail 的教訓） 誠實面對缺點：Strapi 的 Admin UI 確實需要使用者有一定的軟體素養 沒有任何框架或工具是萬能的，Strapi 也不是萬能的，但在我們的場景下——中小型專案、有技術團隊、預算有限、需要快速上線——它是一個極佳的選擇。\n最終結果？ 專案在預定時間內上線，後端開發時間縮短了 60%，雖然我們額外花了四週規劃客製化管理介面(不包括實作時間)，但整體而言仍比自建後端省下大量成本和時間。老闆看到成本分析表後非常滿意——這就是一個成功的技術選型帶來的價值。\n參考資源 官方文件 Strapi 官方文件 Strapi GitHub Wagtail 官方網站 延伸閱讀 Headless CMS 比較指南 Strapi vs Contentful 比較 Strapi 部署最佳實踐 ","permalink":"https://peter-notes.com/posts/why-choose-strapi-cms-2024/","summary":"\u003ch2 id=\"引言一個技術選型的起點\"\u003e引言：一個技術選型的起點\u003c/h2\u003e\n\u003cp\u003e2024 年 6 月，我坐在會議室裡，面對著老闆和行銷總監，準備報告我對公司新系統 CMS 的技術選型建議。這不是一個輕鬆的決定——選錯了，可能浪費數百萬的開發成本；選對了，能為公司省下可觀的人力支出。\u003c/p\u003e\n\u003cp\u003e經過數週的研究與分析，作為一個架構規劃師同時也是技術決策者，我最終選擇了 \u003cstrong\u003eStrapi\u003c/strong\u003e 作為我們的 Headless CMS 解決方案。這篇文章將分享我的決策過程、考量因素，以及最重要的——這個選擇如何為公司省下大筆預算。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"什麼是-headless-cms\"\u003e什麼是 Headless CMS？\u003c/h2\u003e\n\u003cp\u003e在深入 Strapi 之前，先理解 Headless CMS 的核心概念。\u003c/p\u003e\n\u003ch3 id=\"傳統-cms-vs-headless-cms\"\u003e傳統 CMS vs Headless CMS\u003c/h3\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/2025-12-23-why-choose-strapi-cms-diagram-8c88f334.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e\u003cstrong\u003e傳統 CMS\u003c/strong\u003e 將前端與後端緊密耦合，網站的外觀和內容管理綁在一起。而 \u003cstrong\u003eHeadless CMS\u003c/strong\u003e 則專注於內容管理和 API 提供，讓前端團隊可以使用任何技術框架來消費這些 API。\u003c/p\u003e\n\u003ch3 id=\"headless-cms-的核心優勢\"\u003eHeadless CMS 的核心優勢\u003c/h3\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e特性\u003c/th\u003e\n          \u003cth\u003e傳統 CMS\u003c/th\u003e\n          \u003cth\u003eHeadless CMS\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e前後端耦合\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e緊密耦合\u003c/td\u003e\n          \u003ctd\u003e完全分離\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e前端技術選擇\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e受限於 CMS 模板\u003c/td\u003e\n          \u003ctd\u003e任意框架（Vue、React、Flutter）\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e多平台支援\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e僅限網頁\u003c/td\u003e\n          \u003ctd\u003eWeb、App、IoT 皆可\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e擴展性\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e中等\u003c/td\u003e\n          \u003ctd\u003e極高\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eAPI 優先\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e否\u003c/td\u003e\n          \u003ctd\u003e是\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003chr\u003e\n\u003ch2 id=\"為什麼選擇-strapi三大核心原因\"\u003e為什麼選擇 Strapi？三大核心原因\u003c/h2\u003e\n\u003ch3 id=\"原因一前後端完全分離\"\u003e原因一：前後端完全分離\u003c/h3\u003e\n\u003cp\u003eStrapi 作為 Headless CMS，天生就是為了前後端分離而設計。這意味著：\u003c/p\u003e","title":"為什麼技術選型 CMS 我要選 Strapi？2024 年中的預算與系統分析決策"},{"content":"前言：當 Build Failed 成為日常 在過去的 19 個小時裡，我經歷了 15 次 build failed，產生了 15 個 fix commits。如果你覺得這很誇張，讓我告訴你更誇張的：最後一個 bug 是 git describe 在多個 tag 指向同一 commit 時會隨機返回其中一個。\n是的，隨機。在 CI/CD Pipeline 裡。\n這篇文章完整記錄這場除錯馬拉松，從最初的 Fastlane 版本問題，到 Discord 通知功能的實作與修復，再到 Ruby 相容性地獄，最後揭開 git 鮮為人知的行為。泡杯咖啡，這會是一段旅程。\n第一章：Fastlane 與 Bundler 的糾葛 問題 1：Fastlane 版本不一致 Commit: fix(jenkins): use bundle exec for fastlane to ensure version consistency\nJenkins 機器上有全域安裝的 Fastlane，但版本與 Gemfile.lock 指定的不同。這導致某些 action 行為不一致。\n// Before: 使用全域 fastlane sh \u0026#39;fastlane ios build\u0026#39; // After: 透過 Bundler 執行，確保版本一致 sh \u0026#39;bundle exec fastlane ios build\u0026#39; 學習：在 CI 環境中，永遠使用 bundle exec 執行 Ruby 工具，確保版本與 lockfile 一致。\n問題 2：Bundler 權限錯誤 Commit: fix(jenkins): fix bundler permission error with BUNDLE_PATH env var\n加上 bundle exec 後，新問題出現：\nThere was an error while trying to write to `/var/lib/gems/3.0.0/cache` You don\u0026#39;t have write permissions for the /var/lib/gems directory Bundler 預設嘗試寫入系統 gem 目錄。解決方案是設定 BUNDLE_PATH 環境變數：\nenvironment { BUNDLE_PATH = \u0026#34;${env.HOME}/.jenkins/cache/mobile-ci/bundler\u0026#34; } 這不只解決權限問題，還讓 gem 快取跨 build 保留，加速後續建置。\n第二章：Discord Webhook 的誕生與磨難 功能實作：失敗時發送壓縮 Log Commit: feat(jenkins): send compressed build log to Discord on failure\n為了更快除錯，我實作了失敗時自動發送 build log 到 Discord 的功能：\npost { failure { script { // 壓縮 log 檔案 sh \u0026#39;\u0026#39;\u0026#39; tail -n 5000 \u0026#34;${LOG_FILE}\u0026#34; \u0026gt; /tmp/build.log 7z a -mx=9 /tmp/build.7z /tmp/build.log \u0026#39;\u0026#39;\u0026#39; // 發送到 Discord sh \u0026#39;\u0026#39;\u0026#39; curl -F \u0026#34;payload_json=\u0026lt;payload.json\u0026#34; \\ -F \u0026#34;file=@/tmp/build.7z\u0026#34; \\ \u0026#34;${DISCORD_WEBHOOK}\u0026#34; \u0026#39;\u0026#39;\u0026#39; } } } 然而，這只是噩夢的開始。\n問題 3：JSON Payload 格式錯誤 Commit: fix(jenkins): fix Discord webhook JSON payload formatting\nDiscord API 對 JSON 格式非常嚴格。最初的 JSON 因為 Groovy 字串插值問題而損壞：\n// 錯誤：Groovy 會解析 ${} def json = \u0026#39;{\u0026#34;title\u0026#34;: \u0026#34;${env.JOB_NAME}\u0026#34;}\u0026#39; // 結果是空的 // 正確：使用 HEREDOC 避免插值問題 sh \u0026#34;\u0026#34;\u0026#34; cat \u0026gt; /tmp/payload.json \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; {\u0026#34;title\u0026#34;: \u0026#34;Build Failed\u0026#34;, \u0026#34;color\u0026#34;: 15548997} EOF \u0026#34;\u0026#34;\u0026#34; 問題 4：Log 路徑編碼 Commits:\nfix(jenkins): use Jenkins API to get build log refactor(jenkins): simplify Jenkinsfile and fix Discord log fix(jenkins): fix Discord log path encoding for special characters Jenkins 的 job 路徑包含 + 符號（來自 tag 名稱如 prod-0.0.89+2025000231），但在檔案系統中被編碼為 %2B：\n# Tag 名稱 prod-0.0.89+2025000231 # Jenkins job 路徑 mobile-ci/prod-0.0.89%2B2025000231 # 實際 log 檔案路徑 /Users/jenkins/.jenkins/jobs/mobile-ci/jobs/prod-0.0.89%2B2025000231/builds/1/log 最終解決方案：\nENCODED_JOB=\u0026#34;$(echo \u0026#39;${jobName}\u0026#39; | sed \u0026#39;s|+|%2B|g\u0026#39;)\u0026#34; JOB_PATH=\u0026#34;$(echo \u0026#34;$ENCODED_JOB\u0026#34; | sed \u0026#39;s|/|/jobs/|g\u0026#39;)\u0026#34; LOG_FILE=\u0026#34;${JENKINS_HOME}/jobs/${JOB_PATH}/builds/${BUILD_NUMBER}/log\u0026#34; 第三章：CocoaPods 與 Build Number 的迷宮 問題 5：CocoaPods PATH 錯誤 Commit: fix(ci): fix CocoaPods PATH and reduce log verbosity\nFlutter build 找不到正確的 CocoaPods：\nWarning: CocoaPods not installed. Skipping pod install. 原因是 FLUTTER_COCOAPODS_PATH 沒有正確設定。修復：\nsh \u0026#39;\u0026#39;\u0026#39; export FLUTTER_COCOAPODS_PATH=\u0026#34;$(which pod)\u0026#34; fvm flutter build ios --release ... \u0026#39;\u0026#39;\u0026#39; 問題 6：Build Number 驗證失敗 Commit: fix(ci): add build number verification and reduce log verbosity\n我想在 build 完成後驗證 Info.plist 的版本號是否正確：\nACTUAL_BUILD=$(/usr/libexec/PlistBuddy -c \u0026#39;Print CFBundleVersion\u0026#39; ios/Runner/Info.plist) if [ \u0026#34;$ACTUAL_BUILD\u0026#34; != \u0026#34;$EXPECTED_BUILD\u0026#34; ]; then echo \u0026#34;Build number mismatch!\u0026#34; exit 1 fi 結果讀出來的是\u0026hellip; $(FLUTTER_BUILD_NUMBER)？\n問題 7：PlistBuddy 讀到 Xcode 變數 Commits:\nrefactor(fastlane): use flutter build as SSOT for build number fix(fastlane): remove PlistBuddy verification and add manual pod install 原來 Flutter 的 Info.plist 使用 Xcode 變數 $(FLUTTER_BUILD_NUMBER)，實際數值在 Xcode build 時才會替換。PlistBuddy 讀取的是原始檔案，當然只能看到變數名稱。\n解決方案：採用 SSOT（Single Source of Truth）架構，flutter build --build-number 是唯一的版本號來源，移除所有驗證嘗試。\n問題 8：Pod Install 找不到 Gemfile Commit: fix(fastlane): fix pod install to run from project root with Gemfile\ncd ios \u0026amp;\u0026amp; bundle exec pod install # Error: Could not locate Gemfile ios/ 目錄沒有 Gemfile！修復：\n# 不要 cd，使用 --project-directory sh(\u0026#34;bundle exec pod install --project-directory=ios --silent\u0026#34;) 第四章：Ruby 版本地獄 問題 9：Ruby 3.4 與 ActiveSupport 6.1 不相容 Commit: fix(ci): use Ruby 3.3.6 via rbenv for ActiveSupport compatibility\nNameError: uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger Ruby 3.4 將 logger 從標準函式庫移至獨立 gem，ActiveSupport 6.1 沒有正確宣告依賴。\n第一次嘗試：安裝 rbenv，使用 Ruby 3.3.6。\nenvironment { PATH = \u0026#34;${env.HOME}/.rbenv/shims:${env.HOME}/.rbenv/bin:${env.PATH}\u0026#34; RBENV_VERSION = \u0026#34;3.3.6\u0026#34; } 結果：還是失敗。ActiveSupport 6.1.7.10 即使在 Ruby 3.3 上也有 Logger 問題。\n問題 10：ActiveSupport 需要升級到 7.x Commit: fix(ci): upgrade ActiveSupport to 7.x and relax Ruby version constraint\n# Gemfile ruby \u0026#34;\u0026gt;= 3.3.0\u0026#34; # 範圍約束，兼容開發機和 CI gem \u0026#34;activesupport\u0026#34;, \u0026#34;~\u0026gt; 7.0\u0026#34; # 7.x 正確處理 logger 依賴 gem \u0026#34;logger\u0026#34; # 明確宣告 關鍵學習：Ruby 版本約束使用 \u0026quot;\u0026gt;= 3.3.0\u0026quot; 而非 \u0026quot;3.3.6\u0026quot;，讓本機（Ruby 3.4.3）和 CI（Ruby 3.3.6）都能運作。\n第五章：大魔王登場 — git describe 的隨機行為 問題 11：Prod Build 使用 Dev 設定 Commit: fix(ci): use BRANCH_NAME instead of git describe for tag detection\nBuild 成功了！部署到 TestFlight\u0026hellip; 失敗。\nThe bundle version must be higher than the previously uploaded version: \u0026#39;2025000230\u0026#39; 等等，我明明用的是 prod-0.0.89+2025000231，為什麼它說 2025000230？\n檢查 log：\n+ export APP_VERSION=0.2.1 # 應該是 0.0.89！ + export APP_BUILD_NUMBER=2025000230 # 應該是 2025000231！ + bundle exec fastlane ios deploy_dev_to_testflight # 應該是 deploy_prod！ Prod build 完全使用了 dev 的環境變數！\n根本原因：git describe 隨機返回 Tag 問題出在 Jenkinsfile 的 tag 解析：\ndef tagName = sh( script: \u0026#34;git describe --tags --exact-match\u0026#34;, returnStdout: true ).trim() 當多個 tag 指向同一個 commit 時：\n$ git log --oneline -1 dev-0.2.1+2025000230 30c1555 fix(ci): upgrade ActiveSupport... $ git log --oneline -1 prod-0.0.89+2025000231 30c1555 fix(ci): upgrade ActiveSupport... git describe --tags --exact-match 會隨機返回其中一個！這不是 bug，這是 git 的設計行為。\n解決方案 在 Jenkins Multibranch Pipeline 中，BRANCH_NAME 環境變數就是觸發 build 的 tag：\nstage(\u0026#39;Parse Git Tag\u0026#39;) { steps { script { // 優先使用 BRANCH_NAME，它是 Jenkins 設定的觸發 tag def tagName = env.BRANCH_NAME ?: \u0026#39;\u0026#39; if (!tagName) { // Fallback tagName = sh( script: \u0026#34;git describe --tags --exact-match 2\u0026gt;/dev/null || echo \u0026#39;\u0026#39;\u0026#34;, returnStdout: true ).trim() } def matcher = (tagName =~ /^(dev|stg|prod)-(\\d+\\.\\d+\\.\\d+)\\+(\\d+)$/) env.SHORT_ENV = matcher[0][1] env.VERSION = matcher[0][2] env.BUILD_NUM = matcher[0][3] } } } 終於，prod-0.0.89+2025000232 build 成功，部署成功。\n🤖 Build Android (prod) v0.0.89+2025000232 ✅ No errors uploading \u0026#39;...ipa\u0026#39; ✅ Finished: SUCCESS ✅ 完整問題清單 # Commit 問題 根本原因 1 c33e2db Fastlane 版本不一致 未使用 bundle exec 2 96e496e Bundler 權限錯誤 嘗試寫入系統目錄 3 1af99aa Discord 通知功能 （新功能） 4 4009d16 JSON 格式錯誤 Groovy 字串插值 5 66b2354 無法取得 log Jenkins API 問題 6 69b9c47 Log 路徑錯誤 路徑拼接邏輯 7 91d122b 特殊字元編碼 + 號編碼為 %2B 8 b44ca7e CocoaPods 找不到 PATH 未設定 9 bfb6801 版本驗證失敗 PlistBuddy 讀到變數 10 0377373 架構重構 改用 SSOT 11 3faa2a7 移除驗證 Xcode 變數無法讀取 12 2c7dbc5 Pod install 失敗 Gemfile 不在 ios 目錄 13 d1a5fa4 Logger NameError Ruby 3.4 移除標準庫 14 30c1555 仍然失敗 ActiveSupport 6.1 問題 15 2b1e41a 環境變數錯誤 git describe 隨機返回 結語：19 小時的教訓 這場除錯馬拉松教會我幾件事：\nCI/CD 是複雜系統：Jenkinsfile、Fastlane、Ruby、CocoaPods、Flutter、Xcode、App Store Connect、Google Play——任何一環出錯都會導致 build failed。\nLog 是最好的朋友：實作 Discord 通知雖然花了 5 個 commits 才穩定，但之後的除錯效率提升了 10 倍。\n不要假設工具的行為：git describe 的「隨機」行為完全在文件中，但誰會想到在 CI/CD 中這會是問題？\n版本管理很重要：從 bundle exec 到 rbenv，版本不一致是大多數問題的根源。\nSSOT 原則：當多個地方可以設定同一個值時，選一個作為唯一來源，其他地方都從它讀取。\n希望這篇文章能幫你在遇到類似問題時少走一些彎路。\n下次 build failed 時，記得先深呼吸，然後問自己：「有沒有什麼東西是隨機的？」\n","permalink":"https://peter-notes.com/posts/2025-12-21-jenkins-flutter-cicd-debugging-journey/","summary":"\u003ch2 id=\"前言當-build-failed-成為日常\"\u003e前言：當 Build Failed 成為日常\u003c/h2\u003e\n\u003cp\u003e在過去的 19 個小時裡，我經歷了 15 次 build failed，產生了 15 個 fix commits。如果你覺得這很誇張，讓我告訴你更誇張的：最後一個 bug 是 \u003ccode\u003egit describe\u003c/code\u003e 在多個 tag 指向同一 commit 時會\u003cstrong\u003e隨機\u003c/strong\u003e返回其中一個。\u003c/p\u003e\n\u003cp\u003e是的，隨機。在 CI/CD Pipeline 裡。\u003c/p\u003e\n\u003cp\u003e這篇文章完整記錄這場除錯馬拉松，從最初的 Fastlane 版本問題，到 Discord 通知功能的實作與修復，再到 Ruby 相容性地獄，最後揭開 git 鮮為人知的行為。泡杯咖啡，這會是一段旅程。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"第一章fastlane-與-bundler-的糾葛\"\u003e第一章：Fastlane 與 Bundler 的糾葛\u003c/h2\u003e\n\u003ch3 id=\"問題-1fastlane-版本不一致\"\u003e問題 1：Fastlane 版本不一致\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eCommit\u003c/strong\u003e: \u003ccode\u003efix(jenkins): use bundle exec for fastlane to ensure version consistency\u003c/code\u003e\u003c/p\u003e\n\u003cp\u003eJenkins 機器上有全域安裝的 Fastlane，但版本與 Gemfile.lock 指定的不同。這導致某些 action 行為不一致。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-groovy\" data-lang=\"groovy\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// Before: 使用全域 fastlane\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"n\"\u003esh\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;fastlane ios build\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// After: 透過 Bundler 執行，確保版本一致\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"n\"\u003esh\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;bundle exec fastlane ios build\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e學習\u003c/strong\u003e：在 CI 環境中，永遠使用 \u003ccode\u003ebundle exec\u003c/code\u003e 執行 Ruby 工具，確保版本與 lockfile 一致。\u003c/p\u003e","title":"15 次 Build Failed：一場 Jenkins + Flutter CI/CD 的史詩級除錯之旅"},{"content":"前言：一個月燒掉 $100 的真實故事 如果你正在考慮升級 Claude Code 的訂閱方案，或者已經升級了卻發現 token 還是不夠用，那你來對地方了。\n這不是一篇推銷文，而是一個真實的使用心得。\n我從 Pro Plan（$20/月） 開始使用 Claude Code，很快就發現 token 不夠用。心想：「升級到 Max Plan（$100/月）應該就沒問題了吧？」\n結果呢？\nMax Plan 的 token 也不夠用。\n更尷尬的是，我花了大把的 token 去處理一些看似簡單的 bug，像是：\n體脂率的小數點精度問題 排便次數顯示 null 和 0 的差異 這些問題聽起來簡單，實際上卻各花了大量 token 去「探索」程式碼。\n直到我發現問題的根源不是 token 不夠 (Pro plan是真的不夠！)，而是我沒有找到對的 Skill 來處理這類問題。\n問題分析：Token 都燒去哪了？ 讓我用一張圖來說明沒有使用正確 Skill 時的除錯流程：\n看到問題了嗎？\n這就是「漫無目的的探索」——AI 不知道該往哪裡找，所以它嘗試讀取所有可能相關的檔案，每次讀取都在消耗 token。\nToken 消耗的真相 操作類型 Token 消耗 實際價值 讀取不相關的檔案 高 零 廣泛搜尋 grep/glob 中 低（通常需要多次） 隨機嘗試修復 高 可能為負（引入新 bug） 來回確認「這樣對嗎？」 中 低 我的真實案例：為了修一個體脂率顯示的小數點問題，AI 讀取了：\nModel 檔案（2個） ViewModel 檔案（3個） View 檔案（2個） Repository 檔案（2個） GraphQL 查詢檔案（1個） 還有各種「可能相關」的 utils 總共讀了 10+ 個檔案，才找到問題其實只在 2 個檔案裡。\n解決方案：使用正確的 Skill 我安裝了不少 Skill，也自己寫過幾個。但坦白說，在除錯時我一直沒想到要用 systematic-debugging 這個 Skill。\n直到這次踩坑，我才意識到它正是為這種場景設計的。\n什麼是 Systematic Debugging？ 它是一種假設驅動的除錯方法，核心理念是：\n先思考，再行動。先建立假設，再驗證假設。\n使用方式 當你遇到 bug 時，在開始之前先輸入：\n/systematic-debugging 這會啟動一個結構化的除錯流程，AI 會：\n先釐清問題：「你看到了什麼症狀？預期行為是什麼？」 建立假設：「可能的原因有 A、B、C」 設計驗證：「只需要讀這 1-2 個檔案就能驗證假設 A」 精準定位：避免讀取不相關的檔案 實戰對比：有 Skill vs 沒 Skill 案例：體脂率小數點精度問題 問題描述：體脂率應該顯示 4 位小數，但只顯示了 2 位。\n沒有使用 Skill 的流程 我：體脂率只顯示 2 位小數，應該要 4 位 AI：讓我搜尋相關程式碼... [讀取 body_fat_model.dart] [讀取 diary_view_model.dart] [讀取 diary_page.dart] [讀取 body_composition_repository.dart] [讀取 graphql_queries.dart] [讀取 number_formatter.dart] [讀取 diary_card_widget.dart] ... 找到了！問題在 number_formatter 的 toStringAsFixed(2) 消耗：讀取 8+ 個檔案，大量 token\n使用 Skill 的流程 我：/systematic-debugging 體脂率只顯示 2 位小數，應該要 4 位 AI：讓我先釐清問題範圍。 症狀：顯示精度不足 預期：4 位小數 實際：2 位小數 假設優先順序： 1. View 層格式化問題（最常見） 2. ViewModel 層轉換問題 3. Model 層資料問題（較少見） 驗證假設 1：請告訴我顯示體脂率的是哪個頁面？ 我：diary 頁面的 body fat card AI：[只讀取 diary_body_fat_card.dart] 找到了！這裡使用 toStringAsFixed(2)，改成 4 即可。 消耗：讀取 1 個檔案，精準定位\nToken 節省效果 指標 無 Skill 有 Skill 節省 讀取檔案數 8+ 1-2 75-85% 搜尋次數 5+ 1-2 60-80% 總 Token 消耗 高 低 70%+ 解決時間 長 短 50%+ 為什麼會忽略正確的 Skill？ 明明裝了很多 Skill，為什麼遇到 bug 時沒想到用？\n我反思了一下，大概有這些原因：\n情況 心態 Bug 看起來簡單 「這應該很快就能修好，不用特地叫 Skill」 習慣讓 AI 自己處理 「讓它搜就好，反正它會找到」 Skill 太多，不確定用哪個 「systematic-debugging 是做什麼的來著\u0026hellip;」 趕時間 「先讓它跑，我邊看邊引導」 回頭看，這些「省時間」的想法反而花了更多時間和 token。\n如何避免重蹈覆轍 方法 1：在 CLAUDE.md 中加入鐵則 把 Skill 使用規則寫進專案的 CLAUDE.md，讓 Claude Code 每次開啟專案時都會看到：\n## Debugging Rules - IRON LAW - **ALWAYS use `/systematic-debugging` skill BEFORE proposing any fix** - **NEVER explore codebase randomly** - this wastes tokens and time - Follow hypothesis-driven debugging: 1. Define symptoms clearly 2. Form hypotheses about possible causes 3. Design minimal verification steps 4. Eliminate causes systematically with targeted reads 方法 2：記住關鍵場景對應的 Skill # 遇到 bug 時 /systematic-debugging # 需要規劃實作時 /brainstorming # 完成功能要 review 時 /requesting-code-review # 需要寫實作計畫時 /write-plan 方法 3：善用 Explore Agent 當你需要了解程式碼結構時，使用 Task tool 的 Explore agent，而不是讓 AI 自己 grep 來 grep 去：\n「使用 Explore agent 幫我找出處理 user authentication 的相關檔案」 進階技巧：其他節省 Token 的方法 1. 提供精確的上下文 ❌ 不好的問法：\n「幫我修這個 bug」 ✅ 好的問法：\n「diary 頁面的 body_fat_card.dart 中， 體脂率顯示只有 2 位小數，應該要 4 位。 問題可能在格式化的部分。」 2. 善用 CLAUDE.md 把專案架構、命名規則、常見檔案位置寫在 CLAUDE.md 中，AI 就不用每次都從頭探索。\n3. 追蹤資料流 對於顯示問題，先確認資料流：\nAPI → Model → ViewModel → View 問 AI 從哪一層開始檢查，而不是讓它全部都看一遍。\n結論：找對工具比升級方案重要 經過這次經驗，我的心得是：\n1. Skill 是設計來解決特定問題的 每個 Skill 都有它的適用場景。systematic-debugging 就是為了除錯設計的，早點用就能少走彎路。\n2. 寫進 CLAUDE.md 比記在腦子裡可靠 人會忘記，但 CLAUDE.md 每次都會被讀取。把重要的工作流程寫進去，形成「鐵則」。\n3. 升級方案不是解決效率問題的答案 從 $20 升到 $100，如果使用方式不變，很快又會不夠用。\n改變使用方式，找到對的 Skill，才是根本解法。\n希望這篇文章能幫你少走一些彎路。\n如果你也有類似的經驗或其他節省 token 的技巧，歡迎交流。\n延伸閱讀：\nSubagent-Driven 與 Parallel Session：AI 協作開發的兩種典範 寫作時間： 2025-12-20\n","permalink":"https://peter-notes.com/posts/2025-12-20-claude-code-token-optimization-skills/","summary":"\u003ch2 id=\"前言一個月燒掉-100-的真實故事\"\u003e前言：一個月燒掉 $100 的真實故事\u003c/h2\u003e\n\u003cp\u003e如果你正在考慮升級 Claude Code 的訂閱方案，或者已經升級了卻發現 token 還是不夠用，那你來對地方了。\u003c/p\u003e\n\u003cp\u003e這不是一篇推銷文，而是一個真實的使用心得。\u003c/p\u003e\n\u003cp\u003e我從 \u003cstrong\u003ePro Plan（$20/月）\u003c/strong\u003e 開始使用 Claude Code，很快就發現 token 不夠用。心想：「升級到 Max Plan（$100/月）應該就沒問題了吧？」\u003c/p\u003e\n\u003cp\u003e結果呢？\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eMax Plan 的 token 也不夠用。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e更尷尬的是，我花了大把的 token 去處理一些看似簡單的 bug，像是：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e體脂率的小數點精度問題\u003c/li\u003e\n\u003cli\u003e排便次數顯示 null 和 0 的差異\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e這些問題聽起來簡單，實際上卻各花了大量 token 去「探索」程式碼。\u003c/p\u003e\n\u003cp\u003e直到我發現問題的根源不是 token 不夠 (Pro plan是真的不夠！)，而是\u003cstrong\u003e我沒有找到對的 Skill 來處理這類問題\u003c/strong\u003e。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"問題分析token-都燒去哪了\"\u003e問題分析：Token 都燒去哪了？\u003c/h2\u003e\n\u003cp\u003e讓我用一張圖來說明沒有使用正確 Skill 時的除錯流程：\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/2025-12-20-claude-code-token-optimization-skills-diagram-20916c44.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e看到問題了嗎？\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e這就是「漫無目的的探索」\u003c/strong\u003e——AI 不知道該往哪裡找，所以它嘗試讀取所有可能相關的檔案，每次讀取都在消耗 token。\u003c/p\u003e\n\u003ch3 id=\"token-消耗的真相\"\u003eToken 消耗的真相\u003c/h3\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e操作類型\u003c/th\u003e\n          \u003cth\u003eToken 消耗\u003c/th\u003e\n          \u003cth\u003e實際價值\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e讀取不相關的檔案\u003c/td\u003e\n          \u003ctd\u003e高\u003c/td\u003e\n          \u003ctd\u003e零\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e廣泛搜尋 grep/glob\u003c/td\u003e\n          \u003ctd\u003e中\u003c/td\u003e\n          \u003ctd\u003e低（通常需要多次）\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e隨機嘗試修復\u003c/td\u003e\n          \u003ctd\u003e高\u003c/td\u003e\n          \u003ctd\u003e可能為負（引入新 bug）\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e來回確認「這樣對嗎？」\u003c/td\u003e\n          \u003ctd\u003e中\u003c/td\u003e\n          \u003ctd\u003e低\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e我的真實案例：為了修一個體脂率顯示的小數點問題，AI 讀取了：\u003c/p\u003e","title":"Claude Code Token 不夠用？從 $20 升到 $100 還是燒光：我學到的教訓"},{"content":"前言 傳統的前端開發流程往往是這樣的：你在程式碼編輯器中修改 CSS，儲存檔案，切換到瀏覽器查看效果，發現不滿意，再切回編輯器調整⋯⋯這個來回切換的過程不僅耗時，更打斷了創意的流動。\nCursor 的 Browser Visual Editor 正是為了解決這個問題而生。\n這個全新功能將網頁應用程式、程式碼庫和視覺化編輯能力整合在單一介面中，讓開發者可以直觀地操作介面元素，同時保持與程式碼的同步。本文將深入解析這項創新功能的核心概念、運作原理和實際應用場景。\n什麼是 Cursor Browser Visual Editor？ 核心概念 Cursor Browser Visual Editor 是一個整合在 Cursor IDE 中的視覺化編輯工具，它讓開發者能夠：\n直接拖拉介面元素來調整佈局 透過側邊欄即時修改樣式屬性 使用自然語言描述想要的變更 讓 AI 自動更新對應的程式碼 這不只是一個簡單的 CSS 預覽工具，而是一個完整的**設計到程式碼（Design-to-Code）**工作流程。\n省 Token 的關鍵利器 這裡要特別強調一個重要的實用價值：Visual Editor 可以大幅減少你對 AI 下 prompt 的次數，進而節省 Token 用量！\n想像一下傳統的 AI 輔助開發流程：\n❌ 傳統 AI 輔助方式（消耗大量 Token）： 1. \u0026#34;請把這個按鈕的 padding 加大一點\u0026#34; → 消耗 Token 2. 看效果，不滿意 3. \u0026#34;再大一點，然後加個圓角\u0026#34; → 消耗 Token 4. 看效果，顏色不對 5. \u0026#34;背景色換成藍色\u0026#34; → 消耗 Token 6. 還是不滿意... 7. 反覆對話 10 次 → 消耗大量 Token 💸 ✅ Visual Editor 方式（幾乎零 Token）： 1. 直接在面板上拖動 padding 滑桿 → 0 Token 2. 調整 border-radius 數值 → 0 Token 3. 點選色盤換顏色 → 0 Token 4. 即時預覽，滿意為止 → 0 Token 5. 只有複雜變更才需要 AI → 少量 Token ✅ 對於 Cursor 的付費用戶來說，這意味著：\n用量方案可以用更久 減少觸發 Fast Request 限制的機會 把寶貴的 Token 留給真正需要 AI 思考的複雜任務 Visual Editor 主介面總覽 Visual Editor 主介面各功能區域說明\n圖中標註說明：\n編號 功能區域 說明 ① 功能分頁切換 在 Design（設計）、Chat（對話）等不同模式間切換 ② 元素資訊區 顯示當前選取元素的名稱、類型和 DOM 路徑 ③ Sizing 尺寸控制 調整元素的 width、height、min/max 限制 ④ Spacing 間距控制 CSS 屬性檢視與即時編輯（Styles / Computed） ⑤ Effects 效果控制 調整 opacity、shadow、border-radius 等視覺效果 ⑥ 網頁預覽區 即時顯示編輯結果，可直接在此區域點選和拖拉元素 核心功能深度解析 1. 拖拉式佈局操作（Drag-and-Drop Layout） Visual Editor 最直覺的功能就是讓你可以直接在渲染後的頁面上拖拉元素，調整它們在 DOM 樹中的位置。\n直接拖拉介面元素，即時調整佈局結構\n實際應用場景：\n操作類型 傳統方式 Visual Editor Token 消耗 交換按鈕順序 找到程式碼 → 剪下貼上 直接拖拉 0 調整網格排列 修改 CSS grid 屬性 拖拉到目標位置 0 重組導覽列 編輯 JSX/HTML 視覺化拖放 0 測試佈局變化 反覆修改 + 重整 即時預覽 0 這個功能的強大之處在於：你不需要在程式碼中搜尋對應的元件，只要在畫面上直接操作即可。對於大型專案來說，這節省了大量在檔案間切換的時間。\n2. 視覺化 CSS 屬性控制 Visual Editor 的右側提供了強大的互動式屬性控制面板，讓你可以精準調整各種 CSS 屬性：\nCSS 屬性控制面板功能區域說明\n圖中標註說明：\n編號 功能區域 說明 ① Chat 對話區 使用自然語言描述想要的樣式變更 ② 元素選擇器 顯示當前選取的 DOM 元素資訊 ③ 間距與佈局 精細控制 margin、padding、gap、display 屬性 ④ 背景設定 設定背景顏色、漸層、圖片等屬性 ⑤ 網頁預覽區 即時反映所有樣式調整 控制面板功能架構 屬性控制類型：\n控制類型 用途 操作方式 需要 AI? 數值滑桿 padding、margin、font-size 拖動調整數值 ❌ 不需要 色盤選擇器 background、border-color 點選顏色 ❌ 不需要 下拉選單 display、position、flex 選擇預設值 ❌ 不需要 輸入框 精確數值輸入 直接輸入 ❌ 不需要 這些控制元件讓精細調整變得如同使用設計軟體一般直覺，不需要記憶 CSS 屬性名稱，更不需要反覆問 AI「幫我調整一下」。\n3. React 元件狀態測試 對於使用 React 開發的應用程式，Visual Editor 提供了一個元件屬性側邊欄，讓你可以即時測試不同的 props 組合。\n這解決了什麼問題？\n傳統上，測試元件的不同狀態需要：\n修改程式碼中的 props 值 或是使用 Storybook 等額外工具 或是在 React DevTools 中手動操作 現在，你只需要在側邊欄中切換選項，就能即時看到元件在不同狀態下的呈現效果。\n4. 點擊並提示（Click-and-Prompt） 這是 Visual Editor 最具革命性的功能。你可以：\n點擊任何介面元素 用自然語言描述想要的變更 AI 在幾秒內完成修改 什麼時候該用 AI，什麼時候用視覺化控制？\n✅ 用 Visual Editor 面板（省 Token）： - 調整 padding、margin 數值 - 修改顏色 - 調整字體大小 - 改變 border-radius - 切換 display 模式 ✅ 用 AI Prompt（值得花 Token）： - 「把這個卡片改成類似 Apple 官網的風格」 - 「加入滑鼠 hover 時的動畫效果」 - 「讓這個表單在手機版時變成單欄式」 - 「幫我實作這個元件的深色模式」 聰明的開發者會把 Token 花在刀口上：簡單的數值調整用面板，複雜的設計需求才交給 AI。\n技術架構解析 Visual Editor 的整體架構 關鍵技術特點 1. 雙向同步機制\nVisual Editor 保持視覺變更和程式碼的雙向同步：\n視覺 → 程式碼：拖拉元素時，對應的 JSX/CSS 自動更新 程式碼 → 視覺：直接修改程式碼時，預覽也會即時反映 這確保了開發者可以在兩種模式之間自由切換，不會產生不一致的狀態。\n2. 智慧元件識別\nCursor AI 能夠：\n✓ 自動識別點擊的是哪個 React/Vue 元件 ✓ 定位該元件在程式碼庫中的確切位置 ✓ 理解元件的 props 結構和型別定義 ✓ 識別使用的樣式系統（CSS Modules、Tailwind、Styled Components） 3. 設計系統整合\n如果你的專案使用設計 Token 或 CSS 變數，Visual Editor 會自動識別並提供相應的選項：\n/* 專案的設計 Token */ :root { --spacing-sm: 8px; --spacing-md: 16px; --spacing-lg: 24px; --color-primary: #3b82f6; --color-secondary: #64748b; --radius-md: 8px; } 當你調整間距時，面板會優先顯示這些預定義的 Token 值，確保變更符合設計系統規範。\n傳統開發 vs Visual Editor 工作流程比較 關鍵差異：傳統方式需要在編輯器和瀏覽器之間不斷切換，而 Visual Editor 讓你在同一個介面中完成所有操作。\n實際應用場景 場景一：快速原型設計 效率對比：\n階段 傳統流程 Visual Editor Token 節省 建立基礎佈局 2 小時 30 分鐘 ~80% 調整樣式細節 3 小時 45 分鐘 ~90% 測試響應式 1 小時 15 分鐘 ~70% 總計 6 小時 1.5 小時 ~85% 節省約 75% 的開發時間，以及約 85% 的 Token 用量。\n場景二：設計師與開發者協作 設計師可以直接在開發環境中調整介面，變更由 AI 自動轉換為符合專案規範的程式碼，開發者只需審核即可。\n場景三：響應式設計調整 在不同螢幕尺寸下測試和調整佈局時，Visual Editor 讓你可以：\n即時切換視窗大小查看效果 直接拖拉調整斷點樣式 用自然語言描述響應式行為（如：「在手機版把這兩個按鈕改成垂直排列」） 與其他工具的比較 功能 Cursor Visual Editor Figma Dev Mode Chrome DevTools 視覺化編輯 ✅ 完整支援 ❌ 僅檢視 ⚠️ 僅暫時性 CSS 程式碼同步 ✅ 即時雙向同步 ❌ 需手動轉換 ❌ 不會儲存 AI 輔助 ✅ 自然語言控制 ❌ 無 ❌ 無 React 狀態測試 ✅ 內建支援 ❌ 無 ⚠️ 需額外工具 設計系統整合 ✅ 自動識別 Token ✅ 支援 ❌ 無 團隊協作 ✅ 設計師可直接使用 ✅ 設計導向 ❌ 開發者專用 Token 消耗 ⭐ 大幅減少 N/A N/A 最佳實踐建議 1. 建立清晰的元件結構 Visual Editor 在元件邊界清晰時效果最好：\n// ✅ 好的做法：單一職責的小元件 const ActionButton = ({ label, variant, onClick }) =\u0026gt; ( \u0026lt;button className={`btn btn-${variant}`} onClick={onClick}\u0026gt; {label} \u0026lt;/button\u0026gt; ) const CardHeader = ({ title, subtitle }) =\u0026gt; ( \u0026lt;div className=\u0026#34;card-header\u0026#34;\u0026gt; \u0026lt;h3\u0026gt;{title}\u0026lt;/h3\u0026gt; \u0026lt;p\u0026gt;{subtitle}\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; ) // ❌ 避免：數百行的巨型元件 const MegaComponent = ({ ...manyProps }) =\u0026gt; { // 難以在 Visual Editor 中精準選取 } 2. 善用設計 Token 為了讓 Visual Editor 能夠提供一致的選項，建議建立完整的設計 Token：\n:root { /* 間距系統 */ --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-6: 24px; --space-8: 32px; /* 顏色系統 */ --color-primary: #3b82f6; --color-secondary: #64748b; --color-success: #22c55e; --color-warning: #f59e0b; --color-error: #ef4444; /* 圓角系統 */ --radius-sm: 4px; --radius-md: 8px; --radius-lg: 12px; --radius-full: 9999px; } 3. 聰明分配 Token 用量 省 Token 策略：\n🎯 80% 的 UI 調整用 Visual Editor 面板： - 數值調整（padding、margin、size） - 顏色變更 - 基本佈局切換 🤖 20% 的複雜需求用 AI Prompt： - 風格指導（「像 XXX 網站那樣」） - 複雜動畫效果 - 響應式邏輯 - 設計系統整合 4. 使用描述性的自然語言指令 當確實需要使用 AI 時，用整體性的描述會比逐項調整更有效率：\n❌ 低效（多次對話 = 多次 Token 消耗）： \u0026#34;padding 加大\u0026#34; → \u0026#34;再大一點\u0026#34; → \u0026#34;顏色換藍色\u0026#34; → \u0026#34;加個陰影\u0026#34; ✅ 高效（一次到位）： \u0026#34;讓這個卡片看起來更精緻：適當的留白、輕微的陰影浮起效果、使用主色調的邊框\u0026#34; 結語 Cursor Browser Visual Editor 代表了前端開發工具的一個重要轉折點。它打破了「設計」和「開發」之間的傳統界限，讓介面變更變得更直覺、更即時、更有效率。\n核心價值總結：\n優勢 說明 消除上下文切換 在同一個介面中完成設計和開發 加速迭代速度 即時預覽，無需等待編譯 降低協作門檻 設計師也能直接參與介面調整 AI 驅動的智慧化 自然語言取代繁瑣的手動調整 大幅節省 Token 視覺化操作減少 80% 以上的 AI 對話需求 對於 Cursor 的付費用戶來說，Visual Editor 不只是一個方便的功能，更是一個省錢利器。把視覺化調整交給面板，把 Token 留給真正需要 AI 思考的複雜任務，這才是聰明的使用方式。\n隨著 AI 輔助開發工具的持續演進，我們可以期待未來會有更多類似的創新，進一步模糊設計與開發的邊界，讓創意能夠更快地轉化為實際的產品。\n如果你是前端開發者，強烈建議嘗試這個功能，它可能會徹底改變你的開發工作流程——同時也幫你省下不少 Token 費用！\n參考資源 Cursor Browser Visual Editor 官方介紹 Cursor 官方文件 如果你對 AI 開發工具有興趣，歡迎追蹤我的部落格，我會持續分享最新的開發工具評測和實戰心得。\n","permalink":"https://peter-notes.com/posts/2025-12-19-cursor-browser-visual-editor-intro/","summary":"\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e傳統的前端開發流程往往是這樣的：你在程式碼編輯器中修改 CSS，儲存檔案，切換到瀏覽器查看效果，發現不滿意，再切回編輯器調整⋯⋯這個來回切換的過程不僅耗時，更打斷了創意的流動。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eCursor 的 Browser Visual Editor 正是為了解決這個問題而生。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e這個全新功能將網頁應用程式、程式碼庫和視覺化編輯能力整合在單一介面中，讓開發者可以直觀地操作介面元素，同時保持與程式碼的同步。本文將深入解析這項創新功能的核心概念、運作原理和實際應用場景。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"什麼是-cursor-browser-visual-editor\"\u003e什麼是 Cursor Browser Visual Editor？\u003c/h2\u003e\n\u003ch3 id=\"核心概念\"\u003e核心概念\u003c/h3\u003e\n\u003cp\u003eCursor Browser Visual Editor 是一個整合在 Cursor IDE 中的視覺化編輯工具，它讓開發者能夠：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e直接拖拉介面元素\u003c/strong\u003e來調整佈局\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e透過側邊欄\u003c/strong\u003e即時修改樣式屬性\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e使用自然語言\u003c/strong\u003e描述想要的變更\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e讓 AI 自動更新\u003c/strong\u003e對應的程式碼\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e這不只是一個簡單的 CSS 預覽工具，而是一個完整的**設計到程式碼（Design-to-Code）**工作流程。\u003c/p\u003e\n\u003ch3 id=\"省-token-的關鍵利器\"\u003e省 Token 的關鍵利器\u003c/h3\u003e\n\u003cp\u003e這裡要特別強調一個重要的實用價值：\u003cstrong\u003eVisual Editor 可以大幅減少你對 AI 下 prompt 的次數，進而節省 Token 用量！\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e想像一下傳統的 AI 輔助開發流程：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-markdown\" data-lang=\"markdown\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e❌ 傳統 AI 輔助方式（消耗大量 Token）：\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e1.\u003c/span\u003e \u0026#34;請把這個按鈕的 padding 加大一點\u0026#34; → 消耗 Token\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e2.\u003c/span\u003e 看效果，不滿意\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e3.\u003c/span\u003e \u0026#34;再大一點，然後加個圓角\u0026#34; → 消耗 Token\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e4.\u003c/span\u003e 看效果，顏色不對\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e5.\u003c/span\u003e \u0026#34;背景色換成藍色\u0026#34; → 消耗 Token\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e6.\u003c/span\u003e 還是不滿意...\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e7.\u003c/span\u003e 反覆對話 10 次 → 消耗大量 Token 💸\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-markdown\" data-lang=\"markdown\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e✅ Visual Editor 方式（幾乎零 Token）：\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e1.\u003c/span\u003e 直接在面板上拖動 padding 滑桿 → 0 Token\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e2.\u003c/span\u003e 調整 border-radius 數值 → 0 Token\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e3.\u003c/span\u003e 點選色盤換顏色 → 0 Token\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e4.\u003c/span\u003e 即時預覽，滿意為止 → 0 Token\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e5.\u003c/span\u003e 只有複雜變更才需要 AI → 少量 Token ✅\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e對於 Cursor 的付費用戶來說，這意味著：\u003c/strong\u003e\u003c/p\u003e","title":"Cursor Browser Visual Editor 深度解析：AI 驅動的視覺化前端開發革命"},{"content":"引言：選擇正確的壓縮工具為什麼重要？ 在日常開發與維運工作中，我們經常需要壓縮檔案：備份資料庫、傳輸日誌檔、打包部署映像檔。選擇適當的壓縮工具，可能讓您的備份時間從 10 分鐘縮短到 2 分鐘，或是將 500MB 的檔案壓縮到 50MB。\n本文將透過實際測試數據，深入比較常見的 Linux 壓縮工具，幫助您在不同場景下做出最佳選擇。\n測試環境：\nmacOS (Apple Silicon M1 Pro, 10 cores) 測試檔案：PostgreSQL 資料庫備份 (174 MB, my_DB_backup.sql) 測試項目：壓縮率、壓縮速度、解壓速度、特殊功能 壓縮工具快速對照表 工具 壓縮率 壓縮速度 解壓速度 多執行緒 加密 最佳場景 gzip ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ❌ ❌ 通用場景、快速壓縮 pigz ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ✅ ❌ 大檔案快速壓縮 bzip2 ⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐ ❌ ❌ 中等壓縮需求 xz ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐ ✅ ❌ 最高壓縮率 zstd ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ✅ ❌ 平衡速度與壓縮率 7z ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐ ✅ ✅ 跨平台、加密需求 tar - - - - ❌ 打包工具（需搭配壓縮） 1. gzip：經典的壓縮工具 簡介 gzip 是 Linux 系統中最常用的壓縮工具，基於 DEFLATE 演算法（LZ77 + Huffman 編碼）。\n基本使用 # 壓縮檔案（原檔案會被刪除） gzip file.txt # 產生 file.txt.gz # 保留原檔案 gzip -k file.txt # 最高壓縮率（-9） gzip -9 file.txt # 解壓縮 gunzip file.txt.gz # 或 gzip -d file.txt.gz 實測數據（174 MB SQL 檔案） # 壓縮測試 time gzip -9 -k my_DB_backup.sql 項目 數值 原始大小 174 MB 壓縮後大小 26 MB 壓縮率 85.1% 壓縮時間 30 秒 解壓時間 5 秒 優缺點 ✅ 優點：\n廣泛支援，幾乎所有 Linux 系統都預裝 壓縮與解壓速度快 與 tar 完美整合 記憶體使用量低 ⚠️ 缺點：\n不支援多執行緒 壓縮率不如 xz 或 7z 不支援加密 🎯 最佳使用場景：\n日常檔案壓縮 與 tar 結合打包目錄 需要快速解壓的場景 2. pigz：gzip 的多執行緒版本 簡介 pigz (Parallel Implementation of GZip) 是 gzip 的平行化實作，使用多個 CPU 核心加速壓縮。\n安裝與使用 # macOS brew install pigz # Ubuntu/Debian sudo apt install pigz # 使用（語法與 gzip 相同） pigz -9 -k file.txt # 指定執行緒數量 pigz -9 -p 10 file.txt # 使用 10 個核心 實測數據（174 MB SQL 檔案） time pigz -9 -k -p 10 my_DB_backup.sql 項目 數值 原始大小 174 MB 壓縮後大小 26 MB 壓縮率 85.1% (與 gzip 相同) 壓縮時間 8 秒 ⚡ 解壓時間 5 秒 加速比 3.75x (vs gzip) 優缺點 ✅ 優點：\n壓縮速度比 gzip 快 3-4 倍 完全相容 gzip 格式 自動偵測 CPU 核心數 解壓縮也可以平行化 ⚠️ 缺點：\n需要額外安裝 記憶體使用量較高 小檔案加速不明顯 🎯 最佳使用場景：\n大型檔案壓縮（\u0026gt;100MB） 多核心 CPU 系統 CI/CD 流程中需要快速壓縮 效能視覺化 3. bzip2：中等壓縮率的選擇 簡介 bzip2 使用 Burrows-Wheeler 演算法，壓縮率介於 gzip 和 xz 之間。\n基本使用 # 壓縮 bzip2 -9 file.txt # 產生 file.txt.bz2 # 保留原檔案 bzip2 -9 -k file.txt # 解壓縮 bunzip2 file.txt.bz2 實測數據（174 MB SQL 檔案） time bzip2 -9 -k my_DB_backup.sql 項目 數值 原始大小 174 MB 壓縮後大小 23 MB 壓縮率 86.8% 壓縮時間 2 分鐘 解壓時間 45 秒 優缺點 ✅ 優點：\n壓縮率優於 gzip 廣泛支援 錯誤恢復能力較強 ⚠️ 缺點：\n壓縮與解壓都很慢 不支援多執行緒 記憶體使用量較高 🎯 最佳使用場景：\n對壓縮時間不敏感 需要比 gzip 更好的壓縮率 檔案長期歸檔 4. xz：極致壓縮率之王 簡介 xz 使用 LZMA2 演算法，提供最高的壓縮率，常用於 Linux 核心與軟體發行版。\n基本使用 # 壓縮 xz -9 file.txt # 產生 file.txt.xz # 極致壓縮模式 xz -9e file.txt # 多執行緒壓縮 xz -9e -T10 file.txt # 解壓縮 unxz file.txt.xz # 或 xz -d file.txt.xz 實測數據（174 MB SQL 檔案） time xz -9e -T10 -k my_DB_backup.sql 項目 數值 原始大小 174 MB (182,452,738 bytes) 壓縮後大小 18 MB (18,854,476 bytes) 🏆 壓縮率 89.67% 🏆 壓縮時間 1 分 27 秒 解壓時間 8 秒 壓縮等級詳解 # 壓縮等級 0-9 xz -0 # 最快，壓縮率最低 xz -6 # 預設等級 xz -9 # 最高壓縮率 # 極致模式（-e） xz -9e # 比 -9 更高的壓縮率，但速度更慢 記憶體使用 壓縮等級 壓縮記憶體 解壓記憶體 -0 3 MB 1 MB -6 94 MB 9 MB -9 674 MB 65 MB -9e 674 MB 65 MB 優缺點 ✅ 優點：\n最高的壓縮率 支援多執行緒（-T 參數） 解壓速度尚可 廣泛用於軟體發行 ⚠️ 缺點：\n壓縮速度慢 記憶體使用量高（高等級壓縮） 不支援加密 🎯 最佳使用場景：\n需要最小檔案大小 長期歸檔儲存 網路頻寬有限 軟體發行包 實際案例 # Linux 核心原始碼打包 tar -cJf linux-6.6.tar.xz linux-6.6/ # 使用 xz 壓縮，檔案大小通常比 .tar.gz 小 30-40% # 資料庫備份 pg_dump mydb | xz -9e -T$(nproc) \u0026gt; mydb_backup.sql.xz 5. zstd：速度與壓縮率的最佳平衡 簡介 zstd (Zstandard) 由 Facebook 開發，設計目標是在壓縮率與速度之間取得最佳平衡。\n安裝與使用 # macOS brew install zstd # Ubuntu/Debian sudo apt install zstd # 基本壓縮 zstd file.txt # 產生 file.txt.zst # 指定壓縮等級（1-22，預設為 3） zstd -19 file.txt # 高壓縮率 zstd -1 file.txt # 快速壓縮 # 多執行緒 zstd -19 -T10 file.txt # 解壓縮 unzstd file.txt.zst 實測數據（174 MB SQL 檔案） time zstd -19 -T10 my_DB_backup.sql 項目 數值 原始大小 174 MB 壓縮後大小 21 MB 壓縮率 87.9% 壓縮時間 45 秒 解壓時間 3 秒 ⚡ 壓縮等級與速度 # 不同等級的壓縮測試（174 MB 檔案） zstd -1 # 5秒, 30MB (83% 壓縮率) zstd -3 # 8秒, 27MB (84.5% 壓縮率，預設) zstd -10 # 25秒, 23MB (86.8% 壓縮率) zstd -19 # 45秒, 21MB (87.9% 壓縮率) zstd -22 # 90秒, 20MB (88.5% 壓縮率，最高） 優缺點 ✅ 優點：\n解壓縮極快（比 gzip 還快） 壓縮速度與壓縮率平衡極佳 支援多執行緒 可調整的壓縮等級範圍廣（1-22） Facebook、Linux 核心、Docker 等廣泛採用 ⚠️ 缺點：\n需要額外安裝 在某些舊系統上支援度不如 gzip 不支援加密 🎯 最佳使用場景：\nDocker image 壓縮 即時資料壓縮 需要快速解壓的備份 現代化應用部署 實際案例 # Docker image 儲存（節省空間與載入時間） docker save myimage | zstd -19 \u0026gt; myimage.tar.zst # 日誌即時壓縮 tail -f /var/log/app.log | zstd -3 \u0026gt; app.log.zst # Rsync 與 zstd 結合 rsync -av --compress-level=0 src/ dest/ | zstd -3 \u0026gt; sync.log.zst 6. 7z：跨平台與加密的全能選手 簡介 7z 是 7-Zip 的命令列版本，支援多種壓縮演算法，並提供加密功能。\n安裝與使用 # macOS brew install p7zip # Ubuntu/Debian sudo apt install p7zip-full # 基本壓縮 7z a archive.7z file.txt # 極致壓縮（LZMA2） 7z a -t7z -m0=lzma2 -mx=9 archive.7z file.txt # 加密壓縮 7z a -p\u0026#39;MyPassword\u0026#39; -mhe=on archive.7z file.txt # -p: 設定密碼 # -mhe=on: 加密檔案名稱 # 多執行緒 7z a -mmt=10 archive.7z file.txt # 解壓縮 7z x archive.7z 實測數據（174 MB SQL 檔案） time 7z a -t7z -m0=lzma2 -mx=9 -mmt=10 backup.7z my_DB_backup.sql 項目 數值 原始大小 174 MB 壓縮後大小 18.5 MB 壓縮率 89.4% 壓縮時間 1 分 35 秒 解壓時間 10 秒 加密功能 # AES-256 加密 7z a -p\u0026#39;StrongPassword123\u0026#39; -mhe=on -mx=9 secure.7z sensitive_data.sql # 驗證加密 7z l secure.7z # 無法看到檔案列表（因為 -mhe=on） # 解壓縮（需要密碼） 7z x secure.7z # Enter password: **** 支援的格式 # 7z 可以解壓多種格式 7z x file.zip 7z x file.rar 7z x file.tar.gz 7z x file.tar.xz 7z x file.iso 優缺點 ✅ 優點：\n支援加密（AES-256） 極高的壓縮率 支援多種格式 跨平台（Windows/Linux/macOS） 可以建立自解壓縮檔案 ⚠️ 缺點：\n壓縮速度較慢 在 Linux 上需要額外安裝 命令語法較複雜 🎯 最佳使用場景：\n需要加密的敏感資料 跨平台檔案交換 需要最小檔案大小且可接受較長壓縮時間 歸檔多種格式的檔案 7. tar：打包工具（需搭配壓縮） 簡介 tar (Tape ARchive) 本身不是壓縮工具，而是打包工具，通常與壓縮工具結合使用。\n常用組合 # tar + gzip (最常用) tar -czf archive.tar.gz directory/ tar -xzf archive.tar.gz # tar + bzip2 tar -cjf archive.tar.bz2 directory/ tar -xjf archive.tar.bz2 # tar + xz (極致壓縮) tar -cJf archive.tar.xz directory/ tar -xJf archive.tar.xz # tar + zstd (現代化選擇) tar -c directory/ | zstd -19 \u0026gt; archive.tar.zst zstd -dc archive.tar.zst | tar -x 參數詳解 # 常用參數 -c # create (建立) -x # extract (解壓) -t # list (列出內容) -v # verbose (顯示詳細資訊) -f # file (指定檔案名稱) -z # gzip -j # bzip2 -J # xz # 範例 tar -czvf backup.tar.gz /var/www/ # c: 建立 # z: 使用 gzip # v: 顯示過程 # f: 檔案名稱為 backup.tar.gz 進階技巧 # 排除特定檔案 tar -czf backup.tar.gz --exclude=\u0026#39;*.log\u0026#39; --exclude=\u0026#39;node_modules\u0026#39; project/ # 保留權限 tar -czpf backup.tar.gz directory/ # p: 保留檔案權限 # 分卷壓縮（大檔案分割） tar -czf - large_directory/ | split -b 1G - backup.tar.gz.part # 合併解壓 cat backup.tar.gz.part* | tar -xzf - # 使用 pigz 加速 tar -c directory/ | pigz -9 -p 10 \u0026gt; archive.tar.gz # 透過 SSH 遠端備份 tar -czf - /var/www/ | ssh user@remote \u0026#39;cat \u0026gt; backup.tar.gz\u0026#39; 優缺點 ✅ 優點：\n保留檔案權限、所有權、時間戳記 可以打包整個目錄結構 與所有壓縮工具相容 Unix/Linux 標準工具 ⚠️ 缺點：\n本身不壓縮 必須搭配壓縮工具 解壓前無法查看單一檔案 🎯 最佳使用場景：\n備份整個目錄 保留完整的檔案屬性 系統遷移 原始碼發布 壓縮工具效能總覽 實測數據彙整（174 MB SQL 檔案） 工具 壓縮大小 壓縮率 壓縮時間 解壓時間 總評分 xz -9e -T10 18 MB 89.67% 🏆 1:27 8s ⭐⭐⭐⭐⭐ 7z -mx=9 18.5 MB 89.4% 1:35 10s ⭐⭐⭐⭐⭐ zstd -19 21 MB 87.9% 45s 3s 🏆 ⭐⭐⭐⭐⭐ bzip2 -9 23 MB 86.8% 2:00 45s ⭐⭐⭐ gzip -9 26 MB 85.1% 30s 5s ⭐⭐⭐⭐ pigz -9 -p10 26 MB 85.1% 8s 🏆 5s ⭐⭐⭐⭐⭐ 視覺化比較 決策樹：如何選擇壓縮工具？ 實際應用場景建議 1. 資料庫備份 場景： 每日自動備份 PostgreSQL 資料庫\n推薦：xz + 多執行緒\n#!/bin/bash # 資料庫備份腳本 BACKUP_DIR=\u0026#34;/backup/$(date +%Y%m%d)\u0026#34; mkdir -p $BACKUP_DIR # 匯出並壓縮 pg_dump mydb | xz -9e -T$(nproc) \u0026gt; $BACKUP_DIR/mydb_$(date +%Y%m%d_%H%M%S).sql.xz # 清理 30 天前的備份 find /backup -type f -mtime +30 -delete 為什麼選擇 xz？\n最高壓縮率，節省儲存空間 備份通常在夜間執行，時間充裕 恢復時解壓速度可接受 2. CI/CD 流程中的 Docker Image 場景： CI/CD 流程中需要快速壓縮與傳輸 Docker Image\n推薦：zstd\n# GitLab CI/CD 範例 build-and-save: stage: build script: - docker build -t myapp:$CI_COMMIT_SHA . - docker save myapp:$CI_COMMIT_SHA | zstd -10 -T$(nproc) \u0026gt; myapp.tar.zst artifacts: paths: - myapp.tar.zst expire_in: 7 days deploy: stage: deploy script: - zstd -dc myapp.tar.zst | docker load - docker run -d myapp:$CI_COMMIT_SHA 為什麼選擇 zstd？\n壓縮與解壓都很快 壓縮率優於 gzip 適合頻繁的 CI/CD 流程 3. 日誌檔案歸檔 場景： 每日輪替的應用程式日誌\n推薦：pigz\n# logrotate 配置 /var/log/myapp/*.log { daily rotate 90 compress compresscmd /usr/bin/pigz compressoptions -9 -p 4 compressext .gz delaycompress missingok notifempty } 為什麼選擇 pigz？\n與 gzip 完全相容（標準格式） 多執行緒加速 不影響系統效能 4. 長期歸檔儲存 場景： 專案原始碼、設計檔案的長期歸檔\n推薦：xz 或 7z（需要加密）\n# 原始碼歸檔（無加密） tar -cJf project_archive_$(date +%Y%m%d).tar.xz \\ --exclude=\u0026#39;node_modules\u0026#39; \\ --exclude=\u0026#39;.git\u0026#39; \\ project/ # 敏感檔案歸檔（加密） 7z a -t7z -m0=lzma2 -mx=9 -mhe=on -p\u0026#39;StrongPassword\u0026#39; \\ sensitive_archive.7z \\ ./sensitive_data/ 為什麼選擇 xz/7z？\n極致壓縮率，節省長期儲存成本 7z 提供加密選項 歸檔後很少解壓，可接受較長壓縮時間 5. 網站靜態資源 場景： 網站伺服器上的靜態資源預壓縮\n推薦：gzip + brotli（不在本文討論範圍）\n# Nginx 配置 http { # 即時壓縮 gzip on; gzip_vary on; gzip_types text/plain text/css application/json application/javascript; gzip_min_length 1000; # 預壓縮檔案（需要預先產生 .gz 檔案） gzip_static on; } # 預壓縮腳本 find /var/www/html -type f \\( -name \u0026#39;*.js\u0026#39; -o -name \u0026#39;*.css\u0026#39; \\) \\ -exec gzip -9 -k {} \\; 為什麼選擇 gzip？\n瀏覽器廣泛支援 解壓速度極快 標準的 HTTP 壓縮格式 6. 遠端備份傳輸 場景： 透過網路傳輸大型備份檔案\n推薦：zstd + rsync\n# 遠端備份腳本 #!/bin/bash SOURCE=\u0026#34;/data/backup/\u0026#34; DEST=\u0026#34;user@remote:/backup/\u0026#34; # 使用 zstd 壓縮，透過 rsync 傳輸 tar -c $SOURCE | zstd -10 -T$(nproc) | \\ ssh user@remote \u0026#34;cat \u0026gt; /backup/backup_$(date +%Y%m%d).tar.zst\u0026#34; # 或使用 rsync 的內建壓縮 rsync -avz --compress-level=0 $SOURCE $DEST # 注意：rsync 的 -z 使用 zlib，效率不如 zstd 為什麼選擇 zstd？\n快速壓縮，減少網路傳輸時間 解壓速度快，恢復時間短 壓縮率優於 gzip 進階技巧與最佳實踐 1. 壓縮前先評估 # 檢查檔案類型（某些檔案已經壓縮過） file myfile.jpg # JPEG images are already compressed file myfile.mp4 # Video files are already compressed file myfile.pdf # PDFs may be compressed # 已壓縮的檔案再壓縮效果不佳 gzip video.mp4 # 幾乎沒有壓縮效果，反而浪費時間 2. 使用管線組合工具 # 直接壓縮 pg_dump 輸出（不產生中間檔案） pg_dump mydb | xz -9e -T$(nproc) \u0026gt; backup.sql.xz # 遠端備份一步到位 tar -c /var/www | zstd -19 | ssh user@backup \u0026#39;cat \u0026gt; www_backup.tar.zst\u0026#39; # 分卷壓縮大檔案 tar -c large_directory/ | xz -9 | split -b 1G - backup.tar.xz.part 3. 驗證壓縮檔案完整性 # gzip gzip -t file.gz # xz xz -t file.xz # 7z 7z t archive.7z # 壓縮時產生校驗碼 xz -9 file.txt sha256sum file.txt.xz \u0026gt; file.txt.xz.sha256 # 恢復前驗證 sha256sum -c file.txt.xz.sha256 4. 記憶體受限環境 # 降低壓縮等級以減少記憶體使用 xz -6 large_file.txt # 而非 -9 # 或使用記憶體友善的工具 gzip -9 large_file.txt # gzip 記憶體使用量低 5. 自動化壓縮策略 #!/bin/bash # 智慧壓縮腳本：根據檔案大小選擇工具 FILE=$1 SIZE=$(stat -f%z \u0026#34;$FILE\u0026#34;) # macOS # SIZE=$(stat -c%s \u0026#34;$FILE\u0026#34;) # Linux if [ $SIZE -lt 10485760 ]; then # \u0026lt; 10MB: 使用 gzip（快速） echo \u0026#34;Small file, using gzip...\u0026#34; gzip -9 \u0026#34;$FILE\u0026#34; elif [ $SIZE -lt 104857600 ]; then # 10-100MB: 使用 zstd（平衡） echo \u0026#34;Medium file, using zstd...\u0026#34; zstd -19 \u0026#34;$FILE\u0026#34; else # \u0026gt; 100MB: 使用 xz（極致壓縮） echo \u0026#34;Large file, using xz...\u0026#34; xz -9e -T$(nproc) \u0026#34;$FILE\u0026#34; fi 常見問題與解答 Q1: 為什麼 xz 壓縮率最高？ A: xz 使用 LZMA2 演算法，特點是：\n更大的字典大小（dictionary size） 更複雜的匹配演算法 更有效的熵編碼 代價是更高的記憶體使用與更長的壓縮時間。\nQ2: pigz 和 gzip 產生的檔案相容嗎？ A: 完全相容！pigz 產生的 .gz 檔案可以用 gzip 解壓，反之亦然。\n# 用 pigz 壓縮 pigz -9 file.txt # 用 gzip 解壓（沒問題） gunzip file.txt.gz Q3: 我應該使用 tar.gz 還是 tar.xz？ A: 取決於使用場景：\n場景 推薦 原因 軟體發行 tar.xz 下載大小影響用戶體驗 快速備份 tar.gz (或 tar.zst) 時間更重要 長期歸檔 tar.xz 儲存成本考量 相容性優先 tar.gz 最廣泛支援 Q4: 可以壓縮已經壓縮過的檔案嗎？ A: 技術上可以，但沒有意義。\n# 不要這樣做 gzip image.jpg # JPEG 已經壓縮，再壓縮幾乎沒效果 xz video.mp4 # MP4 已經壓縮，浪費時間 # 已壓縮的常見格式 .jpg, .jpeg # 圖片 .mp3, .mp4 # 影音 .zip, .gz # 壓縮檔 .pdf # PDF（通常已壓縮） Q5: 如何選擇壓縮等級？ A: 根據時間敏感度：\n# 快速壓縮（節省時間） gzip -1 file.txt zstd -3 file.txt # 平衡模式（預設） gzip -6 file.txt # 預設 xz -6 file.txt # 預設 # 極致壓縮（節省空間） gzip -9 file.txt xz -9e file.txt 經驗法則：\n日常使用：預設等級就夠了 網路傳輸：使用較高等級 長期歸檔：使用最高等級 效能調校建議 1. 多執行緒最佳實踐 # 不要超過實體核心數 CORES=$(nproc) xz -9e -T$CORES file.txt # 如果是超執行緒 CPU，可以用一半 HALF_CORES=$(($(nproc) / 2)) xz -9e -T$HALF_CORES file.txt # 為其他工作保留 1-2 個核心 RESERVED=2 xz -9e -T$(($(nproc) - $RESERVED)) file.txt 2. I/O 優化 # 使用管線避免中間檔案 tar -c directory/ | xz -9e -T$(nproc) \u0026gt; archive.tar.xz # 而非 tar -cf archive.tar directory/ xz -9e -T$(nproc) archive.tar rm archive.tar # 額外的 I/O 操作 3. SSD vs HDD # SSD: 可以使用更高的壓縮等級（I/O 快） xz -9e -T$(nproc) file.txt # HDD: 降低壓縮等級可能更快（避免 I/O 瓶頸） xz -6 -T$(nproc) file.txt 總結與建議 快速參考指南 情境 推薦工具 命令 🏆 最高壓縮率 xz xz -9e -T$(nproc) file ⚡ 最快壓縮 pigz pigz -9 -p $(nproc) file ⚖️ 平衡選擇 zstd zstd -19 -T$(nproc) file 🔒 需要加密 7z 7z a -p'pwd' -mhe=on file.7z 📦 打包目錄 tar+xz tar -cJf archive.tar.xz dir/ 🌐 通用相容 gzip gzip -9 file 最佳實踐總結 資料庫備份 → xz（極致壓縮） CI/CD 流程 → zstd（快速+好壓縮率） 大檔案 → pigz（多核心加速） 敏感資料 → 7z（加密功能） 日誌歸檔 → pigz（相容性+速度） 長期儲存 → xz（最小體積） 最後的建議 \u0026ldquo;沒有最好的壓縮工具，只有最適合的壓縮工具。\u0026rdquo;\n選擇壓縮工具時，考慮：\n⏱️ 時間是否充裕？ 💾 儲存空間是否有限？ 🔄 解壓頻率高不高？ 🔐 是否需要加密？ 🌍 相容性要求如何？ 記住： 在大多數情況下，zstd 是現代化的最佳平衡選擇，而 xz 適合需要極致壓縮率的場景。\n參考資源 官方文件 gzip 官方手冊 pigz GitHub xz Utils 官網 Zstandard 官網 7-Zip 官網 延伸閱讀 壓縮演算法比較 LZMA vs DEFLATE 演算法 資料壓縮原理 實用工具 # 壓縮率測試工具 brew install hyperfine # 效能測試 brew install dust # 磁碟空間視覺化 # 測試不同壓縮工具 hyperfine --warmup 1 \\ \u0026#39;gzip -9 -c file.txt \u0026gt; /dev/null\u0026#39; \\ \u0026#39;pigz -9 -c file.txt \u0026gt; /dev/null\u0026#39; \\ \u0026#39;xz -9 -c file.txt \u0026gt; /dev/null\u0026#39; \\ \u0026#39;zstd -19 -c file.txt \u0026gt; /dev/null\u0026#39; 文章撰寫日期： 2025-12-19 測試環境： macOS (Apple Silicon M1 Pro) 測試檔案： PostgreSQL 資料庫備份 (174 MB)\n","permalink":"https://peter-notes.com/posts/2025-12-19-compression-tools-comparison/","summary":"\u003ch2 id=\"引言選擇正確的壓縮工具為什麼重要\"\u003e引言：選擇正確的壓縮工具為什麼重要？\u003c/h2\u003e\n\u003cp\u003e在日常開發與維運工作中，我們經常需要壓縮檔案：備份資料庫、傳輸日誌檔、打包部署映像檔。選擇適當的壓縮工具，可能讓您的備份時間從 10 分鐘縮短到 2 分鐘，或是將 500MB 的檔案壓縮到 50MB。\u003c/p\u003e\n\u003cp\u003e本文將透過實際測試數據，深入比較常見的 Linux 壓縮工具，幫助您在不同場景下做出最佳選擇。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e測試環境：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003emacOS (Apple Silicon M1 Pro, 10 cores)\u003c/li\u003e\n\u003cli\u003e測試檔案：PostgreSQL 資料庫備份 (174 MB, my_DB_backup.sql)\u003c/li\u003e\n\u003cli\u003e測試項目：壓縮率、壓縮速度、解壓速度、特殊功能\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"壓縮工具快速對照表\"\u003e壓縮工具快速對照表\u003c/h2\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e工具\u003c/th\u003e\n          \u003cth\u003e壓縮率\u003c/th\u003e\n          \u003cth\u003e壓縮速度\u003c/th\u003e\n          \u003cth\u003e解壓速度\u003c/th\u003e\n          \u003cth\u003e多執行緒\u003c/th\u003e\n          \u003cth\u003e加密\u003c/th\u003e\n          \u003cth\u003e最佳場景\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003egzip\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e⭐⭐⭐\u003c/td\u003e\n          \u003ctd\u003e⭐⭐⭐⭐\u003c/td\u003e\n          \u003ctd\u003e⭐⭐⭐⭐⭐\u003c/td\u003e\n          \u003ctd\u003e❌\u003c/td\u003e\n          \u003ctd\u003e❌\u003c/td\u003e\n          \u003ctd\u003e通用場景、快速壓縮\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003epigz\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e⭐⭐⭐\u003c/td\u003e\n          \u003ctd\u003e⭐⭐⭐⭐⭐\u003c/td\u003e\n          \u003ctd\u003e⭐⭐⭐⭐⭐\u003c/td\u003e\n          \u003ctd\u003e✅\u003c/td\u003e\n          \u003ctd\u003e❌\u003c/td\u003e\n          \u003ctd\u003e大檔案快速壓縮\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003ebzip2\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e⭐⭐⭐⭐\u003c/td\u003e\n          \u003ctd\u003e⭐⭐\u003c/td\u003e\n          \u003ctd\u003e⭐⭐⭐\u003c/td\u003e\n          \u003ctd\u003e❌\u003c/td\u003e\n          \u003ctd\u003e❌\u003c/td\u003e\n          \u003ctd\u003e中等壓縮需求\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003exz\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e⭐⭐⭐⭐⭐\u003c/td\u003e\n          \u003ctd\u003e⭐⭐\u003c/td\u003e\n          \u003ctd\u003e⭐⭐⭐\u003c/td\u003e\n          \u003ctd\u003e✅\u003c/td\u003e\n          \u003ctd\u003e❌\u003c/td\u003e\n          \u003ctd\u003e最高壓縮率\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003ezstd\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e⭐⭐⭐⭐\u003c/td\u003e\n          \u003ctd\u003e⭐⭐⭐⭐⭐\u003c/td\u003e\n          \u003ctd\u003e⭐⭐⭐⭐⭐\u003c/td\u003e\n          \u003ctd\u003e✅\u003c/td\u003e\n          \u003ctd\u003e❌\u003c/td\u003e\n          \u003ctd\u003e平衡速度與壓縮率\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e7z\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e⭐⭐⭐⭐⭐\u003c/td\u003e\n          \u003ctd\u003e⭐⭐\u003c/td\u003e\n          \u003ctd\u003e⭐⭐⭐\u003c/td\u003e\n          \u003ctd\u003e✅\u003c/td\u003e\n          \u003ctd\u003e✅\u003c/td\u003e\n          \u003ctd\u003e跨平台、加密需求\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003etar\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e-\u003c/td\u003e\n          \u003ctd\u003e-\u003c/td\u003e\n          \u003ctd\u003e-\u003c/td\u003e\n          \u003ctd\u003e-\u003c/td\u003e\n          \u003ctd\u003e❌\u003c/td\u003e\n          \u003ctd\u003e打包工具（需搭配壓縮）\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-gzip經典的壓縮工具\"\u003e1. gzip：經典的壓縮工具\u003c/h2\u003e\n\u003ch3 id=\"簡介\"\u003e簡介\u003c/h3\u003e\n\u003cp\u003e\u003ccode\u003egzip\u003c/code\u003e 是 Linux 系統中最常用的壓縮工具，基於 \u003cstrong\u003eDEFLATE\u003c/strong\u003e 演算法（LZ77 + Huffman 編碼）。\u003c/p\u003e","title":"Linux 壓縮工具完全指南：gzip、bzip2、xz、zstd、7z 效能全面比較"},{"content":"為什麼選擇 TWCA OV 證書 在生產環境中，使用自簽憑證會導致瀏覽器顯示不安全警告，影響使用者信任。雖然 Let\u0026rsquo;s Encrypt 提供免費的自動化證書，但某些企業或政府專案需要台灣在地的認證機構簽發證書以符合法規要求。\nSSL 證書的三個等級 SSL 證書依據驗證強度分為三個等級：\n證書類型 驗證內容 適用場景 信任標記 價格 DV (Domain Validation) 僅驗證網域所有權 個人網站、部落格 🔒 基本鎖頭 免費～低 OV (Organization Validation) 驗證組織身份 企業網站、SaaS 服務 🔒 組織名稱 中 EV (Extended Validation) 嚴格驗證企業 金融、電商、政府 🟢 綠色網址列 高 本文使用的是 TWCA OV SSL 證書，它提供：\n✅ 組織身份驗證（瀏覽器可顯示公司名稱） ✅ 完整的證書鏈（受所有主流瀏覽器信任） ✅ 12 個月有效期 ✅ 台灣在地技術支援 HTTPS 與 SSL/TLS 運作原理 在深入部署之前，先理解 HTTPS 如何保護資料傳輸：\n關鍵機制說明：\n非對稱加密（RSA/ECDSA）：只用於金鑰交換，確保 session key 安全傳輸 對稱加密（AES）：實際資料傳輸使用，效能更好 證書鏈驗證（完全離線）：瀏覽器使用內建的 TWCA 根憑證驗證整個證書鏈，不需要連線到 TWCA 中間人攻擊防護：因為攻擊者沒有伺服器的私鑰，無法解密通訊 💡 重要觀念： TWCA 只在簽發證書時參與，TLS 握手過程中完全不涉及。瀏覽器使用內建的根憑證庫（包含 TWCA 根憑證）進行離線驗證。\n證書鏈驗證的實際過程 ┌──────────────────────────────────────────────┐ │ 瀏覽器（內建根憑證庫） │ ├──────────────────────────────────────────────┤ │ │ │ Server 傳來的證書鏈： │ │ ┌───────────────────────────────┐ │ │ │ ① www.myCompany.com 證書 │ ← Server │ │ │ (由 TWCA 中繼憑證簽發) │ │ │ └───────────┬───────────────────┘ │ │ ↓ 用中繼憑證公鑰驗證 │ │ ┌───────────────────────────────┐ │ │ │ ② TWCA Secure SSL CA 中繼憑證 │ ← Server │ │ │ (由 TWCA 根憑證簽發) │ │ │ └───────────┬───────────────────┘ │ │ ↓ 用根憑證公鑰驗證 │ │ ┌───────────────────────────────┐ │ │ │ ③ TWCA Root CA 根憑證 │ ← 瀏覽器 │ │ │ (內建信任清單) │ 內建 │ │ └───────────────────────────────┘ │ │ ✓ 信任鏈完整 │ └──────────────────────────────────────────────┘ TWCA 證書申請與 DNS 驗證 申請流程 TWCA 採用 ACME (Automatic Certificate Management Environment) 協議進行網域驗證。申請證書時，你需要：\n準備 CSR (Certificate Signing Request)：包含網域名稱、組織資訊和公鑰 進行 DNS TXT 記錄驗證：證明你擁有網域控制權 Route53 在驗證中的角色 AWS Route53 是托管 DNS 服務，在 SSL 證書驗證流程中扮演關鍵角色：\nRoute53 提供的優勢：\nAPI 整合：可透過 AWS CLI 或 SDK 自動化 DNS 記錄管理 即時生效：記錄變更通常在 60 秒內全球傳播 高可用性：99.99% SLA 保證 安全性：支援 DNSSEC 和 IAM 權限控制 DNS 驗證實作 TWCA 會提供一個驗證字串，你需要在 DNS 中建立 TXT 記錄：\n# 使用 AWS Route53 建立 ACME 驗證記錄 aws route53 change-resource-record-sets \\ --hosted-zone-id Z01234567XOQOJYWWWUAC \\ --change-batch file://dns-challenge.json 驗證記錄格式範例：\n{ \u0026#34;Comment\u0026#34;: \u0026#34;ACME DNS challenge for TWCA OV SSL\u0026#34;, \u0026#34;Changes\u0026#34;: [{ \u0026#34;Action\u0026#34;: \u0026#34;CREATE\u0026#34;, \u0026#34;ResourceRecordSet\u0026#34;: { \u0026#34;Name\u0026#34;: \u0026#34;_acme-challenge.backend.myCompany.com.\u0026#34;, \u0026#34;Type\u0026#34;: \u0026#34;TXT\u0026#34;, \u0026#34;TTL\u0026#34;: 300, \u0026#34;ResourceRecords\u0026#34;: [{ \u0026#34;Value\u0026#34;: \u0026#34;\\\u0026#34;Kg7PqRsT9uVwXyZaBCdEfGhIjKlMnOpQ2rS3tU4vW\\\u0026#34;\u0026#34; }] } }] } 為什麼使用 DNS 驗證？\n適合 Kubernetes 環境：不需要在容器內部署驗證檔案 支援萬用字元證書：如 *.myCompany.com 自動化友善：可整合到 CI/CD 流程 安全性高：驗證過程不暴露伺服器資訊 驗證記錄建立後，確認 DNS 已生效：\n# 驗證 DNS 記錄是否可查詢 dig _acme-challenge.backend.myCompany.com TXT +short # 預期輸出 \u0026#34;Kg7PqRsT9uVwXyZaBCdEfGhIjKlMnOpQ2rS3tU4vW\u0026#34; TWCA 驗證通過後，即可下載證書檔案（certificate.crt）和中繼憑證（ca-bundle.crt）。\nKubernetes TLS Secret 深度解析 什麼是 TLS Secret？ Kubernetes Secret 是一種用於儲存敏感資料的資源物件。TLS Secret 是 Secret 的特殊類型，專門用來儲存 SSL/TLS 證書。\nSecret 的底層機制：\n為什麼使用 Secret？\n加密儲存：Secret 在 etcd 中可以加密儲存（需啟用 encryption at rest） 存取控制：透過 RBAC 限制誰能讀取 Secret 自動掛載：Ingress Controller 自動讀取並套用證書 版本控制：更新 Secret 後，Ingress 自動重載新證書 建立 TLS Secret # 合併證書鏈（重要！） cat certificate.crt ca-bundle.crt \u0026gt; fullchain.crt # 建立 TLS Secret kubectl create secret tls backend-tls-secret \\ --cert=fullchain.crt \\ --key=private.key \\ -n default 為什麼要合併證書鏈？\n瀏覽器 伺服器證書 中繼憑證 根憑證 │ │ │ │ ├─────驗證──────────-─→│ │ │ │ ├─────由誰簽發？──────→│ │ │ │ ├─────由誰簽發？──-─→│ │ │ │ │ │←────信任鏈完整────-──┴────────────────────┴──────(根憑證在瀏覽器內建) 如果缺少中繼憑證，部分瀏覽器（尤其是移動裝置）會顯示「證書不受信任」。\n驗證 Secret 建立成功：\n# 檢查 Secret 內容 kubectl get secret backend-tls-secret -n default -o yaml # 解碼並檢查證書資訊 kubectl get secret backend-tls-secret -n default \\ -o jsonpath=\u0026#39;{.data.tls\\.crt}\u0026#39; | \\ base64 -d | \\ openssl x509 -noout -text | \\ grep -E \u0026#34;(Issuer:|Subject:|Not After)\u0026#34; 預期輸出：\nIssuer: C=TW, O=TAIWAN-CA, CN=TWCA Secure SSL Certification Authority Subject: C=TW, ST=Taiwan, L=Taipei, O=ITCompany Inc., CN=backend.myCompany.com Not After : Apr 28 15:59:59 2026 GMT Kubernetes Ingress 部署 Ingress 資源配置 Ingress 是 Kubernetes 中負責 HTTP/HTTPS 流量路由的資源。我們需要將 TLS Secret 綁定到 Ingress 上：\napiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: myCompany-strapi-ingress namespace: default annotations: # SSL 強制重定向 nginx.ingress.kubernetes.io/force-ssl-redirect: \u0026#34;true\u0026#34; nginx.ingress.kubernetes.io/ssl-redirect: \u0026#34;true\u0026#34; # 檔案上傳大小限制 nginx.ingress.kubernetes.io/proxy-body-size: \u0026#34;4096m\u0026#34; # 啟用 HSTS（下方詳細說明） nginx.ingress.kubernetes.io/configuration-snippet: | add_header Strict-Transport-Security \u0026#34;max-age=31536000; includeSubDomains; preload\u0026#34; always; spec: ingressClassName: nginx tls: - hosts: - backend.myCompany.com secretName: backend-tls-secret # 引用 TLS Secret rules: - host: backend.myCompany.com http: paths: - path: / pathType: Prefix backend: service: name: myCompany-strapi-service-prod port: number: 80 套用配置：\nkubectl apply -f ingress.yaml # 檢查 Ingress 狀態 kubectl get ingress myCompany-strapi-ingress -n default SSL 強制重定向機制 Nginx Ingress Controller 會自動處理 HTTP 到 HTTPS 的跳轉：\n為什麼使用 308 而非 301？\n狀態碼 說明 HTTP 方法保留 用途 301 Moved Permanently ❌ POST → GET 網址永久變更 302 Found (臨時重定向) ❌ POST → GET 暫時性跳轉 308 Permanent Redirect ✅ POST 保持 POST SSL 強制重定向 使用 308 確保 POST 請求不會被轉換成 GET，避免資料遺失。\nHSTS：強化 HTTPS 安全性 什麼是 HSTS？ HSTS (HTTP Strict Transport Security) 是一種安全機制，強制瀏覽器只使用 HTTPS 連線，防止降級攻擊。\n沒有 HSTS 的風險：\n啟用 HSTS 後：\nHSTS 標頭解析 Strict-Transport-Security: max-age=31536000; includeSubDomains; preload 參數說明：\nmax-age=31536000：有效期 1 年（秒數） includeSubDomains：套用到所有子網域（如 api.myCompany.com） preload：可申請加入瀏覽器內建的 HSTS 清單 ⚠️ 警告： 啟用 HSTS 後，如果證書過期或配置錯誤，使用者將完全無法訪問網站（瀏覽器會拒絕連線）。請確保證書自動更新機制正常運作。\nAWS 提供的自動化工具 AWS Certificate Manager (ACM) 如果你的服務運行在 AWS Load Balancer 後方，可以使用 ACM 免費簽發證書：\n優勢：\n✅ 完全免費（DV 證書） ✅ 自動更新（永不過期） ✅ 與 ELB/ALB 無縫整合 限制：\n❌ 證書只能用於 AWS 服務（無法匯出私鑰） ❌ 不支援 OV/EV 證書 ❌ 必須使用 AWS Load Balancer cert-manager：Kubernetes 自動化證書管理 cert-manager 是 Kubernetes 生態中的證書管理工具，可自動化申請和更新證書：\n安裝 cert-manager：\n# 安裝 cert-manager kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.0/cert-manager.yaml # 配置 Route53 DNS 驗證 kubectl create secret generic route53-credentials \\ --from-literal=secret-access-key=$AWS_SECRET_ACCESS_KEY \\ -n cert-manager 建立 Certificate 資源（自動更新）：\napiVersion: cert-manager.io/v1 kind: Certificate metadata: name: backend-cert namespace: default spec: secretName: backend-tls-secret issuerRef: name: letsencrypt-prod kind: ClusterIssuer dnsNames: - backend.myCompany.com # 自動在到期前 30 天更新 renewBefore: 720h ✅ 最佳實踐： 對於需要自動更新的 Let\u0026rsquo;s Encrypt 證書，使用 cert-manager。對於需要 OV/EV 等級的企業證書，使用 TWCA 並設定到期提醒 (可以建立一個 Kubernetes CronJob - 自動化且不需要額外的監控基礎設施，定期檢查憑證並在即將到期時發送通知)。\n驗證與測試 證書檢查 驗證證書已正確部署：\n# 檢查證書頒發者和有效期 echo | openssl s_client -servername backend.myCompany.com \\ -connect backend.myCompany.com:443 2\u0026gt;/dev/null | \\ openssl x509 -noout -text | \\ grep -E \u0026#34;(Issuer:|Subject:|Not After|DNS:)\u0026#34; 預期輸出：\nIssuer: C=TW, O=TAIWAN-CA, CN=TWCA Secure SSL Certification Authority Subject: C=TW, ST=Taiwan, L=Taipei, O=ITCompany Inc., CN=backend.myCompany.com Not After : Apr 28 15:59:59 2026 GMT DNS:backend.myCompany.com HTTPS 功能測試 測試 HTTP 到 HTTPS 的自動重定向：\n# 測試重定向 curl -I http://backend.myCompany.com # 預期輸出 HTTP/1.1 308 Permanent Redirect Location: https://backend.myCompany.com 測試 HSTS 標頭：\n# 檢查 HSTS 標頭 curl -I https://backend.myCompany.com | grep -i strict # 預期輸出 Strict-Transport-Security: max-age=31536000; includeSubDomains; preload 完整的安全性檢查清單：\n檢查項目 驗證方法 預期結果 ✓ TLS 版本 curl -I https://... TLS 1.2 或 1.3 ✓ 證書鏈 openssl s_client -showcerts 完整鏈（3 層） ✓ HSTS 標頭 curl -I https://... max-age=31536000 ✓ HTTP 重定向 curl -I http://... 308 Permanent Redirect ✓ 混合內容 瀏覽器開發者工具 無警告 清理 DNS 驗證記錄 證書部署完成後，ACME 驗證用的 DNS TXT 記錄可以安全刪除：\n# 刪除 ACME challenge 記錄 aws route53 change-resource-record-sets \\ --hosted-zone-id Z01234567XOQOJYWWWUAC \\ --change-batch file://delete-acme-records.json 注意： 不要刪除其他重要的 DNS 記錄（如 _dmarc 用於郵件驗證）。\n驗證刪除成功：\n# 確認記錄已刪除 aws route53 list-resource-record-sets \\ --hosted-zone-id Z01234567XOQOJYWWWUAC \\ --query \u0026#34;ResourceRecordSets[?Type==\u0026#39;TXT\u0026#39; \u0026amp;\u0026amp; contains(Name, \u0026#39;_acme-challenge\u0026#39;)]\u0026#34; # 預期輸出 [] 最佳實踐與注意事項 證書更新管理 TWCA OV 證書有效期約 12 個月，需要定期更新：\n更新流程：\n提前 30 天續約：避免證書過期導致服務中斷 設定監控告警： # 檢查證書到期時間 echo | openssl s_client -servername backend.myCompany.com \\ -connect backend.myCompany.com:443 2\u0026gt;/dev/null | \\ openssl x509 -noout -enddate 自動化流程：建立 CI/CD pipeline 自動更新證書 推薦工具：\nPrometheus + Alertmanager：監控證書到期時間 cert-manager：自動更新 Let\u0026rsquo;s Encrypt 證書 AWS Lambda：定期檢查並發送提醒 常見問題處理 問題 1：證書鏈不完整\n症狀：部分瀏覽器顯示「證書不受信任」 原因：未包含中繼憑證（ca-bundle.crt） 解決：cat certificate.crt ca-bundle.crt \u0026gt; fullchain.crt 問題 2：私鑰不匹配\n症狀：Ingress 無法啟動，日誌顯示 \u0026ldquo;key values mismatch\u0026rdquo; 原因：私鑰與 CSR 不對應 檢查： # 檢查證書和私鑰是否匹配 openssl x509 -noout -modulus -in certificate.crt | openssl md5 openssl rsa -noout -modulus -in private.key | openssl md5 # 兩個 MD5 值必須相同 問題 3：DNS 驗證失敗\n症狀：TWCA 無法驗證網域所有權\n原因：DNS 記錄未生效或 TTL 過長\n解決：\n方法 1：使用命令列工具\n# 使用 dig 確認記錄已生效 dig _acme-challenge.backend.myCompany.com TXT +short # 預期輸出（範例） \u0026#34;Kg7PqRsT9uVwXyZaBCdEfGhIjKlMnOpQ2rS3tU4vW\u0026#34; 方法 2：使用 Google Admin Toolbox（推薦）\n如果你沒有安裝 dig 命令，可以使用 Google 提供的線上工具：\n開啟 Google Admin Toolbox - Dig 在 Name 欄位填入：abc.com 在 Type 下拉選單選擇：TXT 點選 Dig 按鈕 預期看到的結果：\n;; ANSWER SECTION: abc.com. 300 IN TXT \u0026#34;Kg7PqRsT9uVwXyZaBCdEfGhIjKlMnOpQ2rS3tU4vW\u0026#34; 如果看到上述記錄，表示 DNS 已正確設定且已傳播生效。\n方法 3：檢查全球 DNS 傳播狀態\n使用 whatsmydns.net 檢查全球不同地區的 DNS 伺服器是否都能查到記錄：\n輸入網域：myCompany.com 選擇記錄類型：TXT 查看世界各地的 DNS 伺服器回應（通常需要 5-10 分鐘全球傳播） 安全性建議 私鑰保護：\n# 設定 RBAC 限制 Secret 存取 kubectl create role secret-reader \\ --verb=get --resource=secrets kubectl create rolebinding secret-reader-binding \\ --role=secret-reader \\ --serviceaccount=default:ingress-controller 定期更換：即使未到期，建議每 3 個月更換私鑰\n監控異常：\n# 檢查 Ingress 日誌，偵測 TLS 握手失敗 kubectl logs -n ingress-nginx deployment/ingress-nginx-controller | \\ grep \u0026#34;SSL_do_handshake\u0026#34; 啟用證書透明度監控：使用 crt.sh 監控你的網域是否有未授權的證書簽發\n總結 在 Kubernetes 環境部署 TWCA OV SSL 證書需要完整理解：\n核心概念：\n✅ SSL 分級：DV/OV/EV 的差異與適用場景 ✅ HTTPS 原理：TLS 握手、證書鏈驗證、金鑰交換 ✅ TLS Secret：Kubernetes 中儲存證書的機制 ✅ HSTS：強制 HTTPS 的安全標頭 關鍵步驟：\nDNS 驗證：透過 Route53 建立 ACME challenge TXT 記錄 TLS Secret：正確包含證書鏈和私鑰 Ingress 配置：啟用 SSL 強制重定向和 HSTS 自動化工具：使用 cert-manager 自動更新證書 AWS 整合：\nRoute53：DNS 管理和 ACME 驗證 ACM：適合 AWS Load Balancer 的免費證書 cert-manager：Kubernetes 原生的證書自動化 透過正確的配置，TWCA OV 證書能為你的 Kubernetes 應用提供企業級的 SSL/TLS 加密保護，同時滿足台灣在地的合規要求。\n","permalink":"https://peter-notes.com/posts/2025-12-10-kubernetes-twca-ssl-deployment/","summary":"\u003ch2 id=\"為什麼選擇-twca-ov-證書\"\u003e為什麼選擇 TWCA OV 證書\u003c/h2\u003e\n\u003cp\u003e在生產環境中，使用自簽憑證會導致瀏覽器顯示不安全警告，影響使用者信任。雖然 Let\u0026rsquo;s Encrypt 提供免費的自動化證書，但某些企業或政府專案需要台灣在地的認證機構簽發證書以符合法規要求。\u003c/p\u003e\n\u003ch3 id=\"ssl-證書的三個等級\"\u003eSSL 證書的三個等級\u003c/h3\u003e\n\u003cp\u003eSSL 證書依據驗證強度分為三個等級：\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e證書類型\u003c/th\u003e\n          \u003cth\u003e驗證內容\u003c/th\u003e\n          \u003cth\u003e適用場景\u003c/th\u003e\n          \u003cth\u003e信任標記\u003c/th\u003e\n          \u003cth\u003e價格\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eDV (Domain Validation)\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e僅驗證網域所有權\u003c/td\u003e\n          \u003ctd\u003e個人網站、部落格\u003c/td\u003e\n          \u003ctd\u003e🔒 基本鎖頭\u003c/td\u003e\n          \u003ctd\u003e免費～低\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eOV (Organization Validation)\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e驗證組織身份\u003c/td\u003e\n          \u003ctd\u003e企業網站、SaaS 服務\u003c/td\u003e\n          \u003ctd\u003e🔒 組織名稱\u003c/td\u003e\n          \u003ctd\u003e中\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eEV (Extended Validation)\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e嚴格驗證企業\u003c/td\u003e\n          \u003ctd\u003e金融、電商、政府\u003c/td\u003e\n          \u003ctd\u003e🟢 綠色網址列\u003c/td\u003e\n          \u003ctd\u003e高\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e\u003cstrong\u003e本文使用的是 TWCA OV SSL 證書\u003c/strong\u003e，它提供：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e✅ 組織身份驗證（瀏覽器可顯示公司名稱）\u003c/li\u003e\n\u003cli\u003e✅ 完整的證書鏈（受所有主流瀏覽器信任）\u003c/li\u003e\n\u003cli\u003e✅ 12 個月有效期\u003c/li\u003e\n\u003cli\u003e✅ 台灣在地技術支援\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"https-與-ssltls-運作原理\"\u003eHTTPS 與 SSL/TLS 運作原理\u003c/h2\u003e\n\u003cp\u003e在深入部署之前，先理解 HTTPS 如何保護資料傳輸：\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/2025-12-10-kubernetes-twca-ssl-deployment-diagram-a110337c.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e\u003cstrong\u003e關鍵機制說明：\u003c/strong\u003e\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e非對稱加密（RSA/ECDSA）\u003c/strong\u003e：只用於金鑰交換，確保 session key 安全傳輸\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e對稱加密（AES）\u003c/strong\u003e：實際資料傳輸使用，效能更好\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e證書鏈驗證（完全離線）\u003c/strong\u003e：瀏覽器使用內建的 TWCA 根憑證驗證整個證書鏈，\u003cstrong\u003e不需要連線到 TWCA\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e中間人攻擊防護\u003c/strong\u003e：因為攻擊者沒有伺服器的私鑰，無法解密通訊\u003c/li\u003e\n\u003c/ol\u003e\n\u003cblockquote\u003e\n\u003cp\u003e💡 \u003cstrong\u003e重要觀念：\u003c/strong\u003e TWCA 只在簽發證書時參與，TLS 握手過程中完全不涉及。瀏覽器使用內建的根憑證庫（包含 TWCA 根憑證）進行離線驗證。\u003c/p\u003e","title":"在 Kubernetes 上部署 OV SSL 證書：完整實戰指南"},{"content":"故事的開始：會員資料集體消失事件 最近收到許多會員的反映，問題驚人地一致：\n📝 「我的日記資料不見了！」 👤 「帳號被自動登出，要重新登入」 ⏳「App 一直轉圈圈，什麼都看不到」 😰 「重新開啟 App 也一樣，資料都不見了」 更詭異的是，這些問題似乎沒有規律性，有的使用者正常，有的使用者卻深受其擾。身為技術負責人，這讓我立刻警覺：這不是資料遺失，而是認證機制出問題了。\n調查過程：抽絲剝繭找出真相 問題時間軸 第一步：確認資料庫完整性 我的第一個懷疑是資料庫是否真的遺失資料。登入後台查詢，發現：\n✅ 關鍵發現： 所有會員的日記資料都完好無缺\n這代表問題不在資料層，而是在存取權限上。\n第二步：檢查使用者行為模式 整理會員回報的時間點，我發現一個規律：\n會員 註冊時間 問題發生時間 間隔 會員 A 30 天前 今天 30 天 會員 B 29 天前 今天 29 天 會員 C 3 天前 正常使用 - 關鍵字：30 天 ⚡\n第三步：檢查 JWT Token 配置 翻開 Strapi 後端的配置檔，真相大白：\n// Strapi 預設 JWT 設定 jwt: { expiresIn: \u0026#39;30d\u0026#39; // Token 有效期：30 天 } 問題根本原因 💡 關鍵洞察： 使用者以為資料不見了，實際上只是「看不到」而已。資料仍安全地存在資料庫中，只是 Token 過期導致無法存取。\n合約檢視：外包商其實該做的事 在確認 Token 過期是問題根源後，我突然想起一件事：\n「當初的開發規格書裡，有沒有要求 Token 管理機制？」\n翻開合約文件 我打開當初簽訂的《IT 系統設計規格書》，在第 15 頁「共通性要求」找到了這一句：\n- OAuth 2.0 (for 第三方登入 / Apple Health \u0026amp; Google Fit) 等等\u0026hellip; OAuth 2.0？\nOAuth 2.0 標準包含什麼？ 合約要求 vs 實際交付 項目 合約要求 外包商交付 狀態 OAuth 2.0 for 第三方登入 ✅ 明確要求 ⚠️ 只做登入，沒做 Refresh Token 🔴 不完整 Access Token ✅ OAuth 2.0 標準 ✅ 有實作 🟢 完成 Refresh Token ✅ OAuth 2.0 標準 ❌ 沒有實作 🔴 缺失 Token 自動更新 ✅ OAuth 2.0 標準 ❌ 沒有實作 🔴 缺失 記住登入狀態 ✅ OAuth 2.0 隱含 ❌ 沒有實作 🔴 缺失 OAuth 2.0 完整實作 vs 外包商交付 技術規格解讀 根據 OAuth 2.0 標準（RFC 6749），完整的實作應該包含：\n1. Authorization（授權） → ✅ 外包商有做\n使用者透過第三方（LINE/Google）授權 2. Token Issuance（發放 Token） → ⚠️ 外包商只做一半\n應返回：Access Token + Refresh Token 實際只返回：Access Token 3. Token Refresh（刷新 Token） → ❌ 外包商沒做\n應該：自動用 Refresh Token 換新 Access Token 實際：無此機制，Token 過期就登出 這不是新需求，是合約範圍內 💡 重要發現： 這不是我「沒有要求」，而是外包商「沒有完整實作 OAuth 2.0」。\n為什麼外包商可能省略了 Refresh Token？ 可能原因分析：\n知識不足\n誤以為 OAuth 2.0 只是「第三方登入」 不了解完整的 Token 管理機制 簡化實作\nRefresh Token 需要額外的後端邏輯 想節省開發時間 驗收不嚴謹\n驗收時只測試「能登入」 沒有測試「長期使用」的情境 溝通不良\n規格書寫「OAuth 2.0」但沒有詳細展開 雙方對技術理解有落差 緊急處理：治標不治本的應急方案 時間壓力下的決策 由於外包商開發的 App 還需要重新開發並送審（iOS 審核通常要 1-2 週），我必須立即止血。\n緊急方案：延長 Token 有效期 // config/server.ts jwt: { - expiresIn: \u0026#39;30d\u0026#39; // 原本：30 天 + expiresIn: \u0026#39;90d\u0026#39; // 緊急調整：90 天（3 月） } 這個方案的利弊分析 項目 評估 說明 執行速度 ⚡ 立即 修改配置後重啟即可 影響範圍 ✅ 最小 現有使用者不受影響 緩衝時間 ✅ 充足 爭取到 6 個月開發時間 安全性 ⚠️ 降低 Token 生命週期過長 長期可行性 ❌ 不佳 6 個月後問題會再次發生 ⚠️ 重要提醒： 這只是暫時的應急措施，不是長期解決方案。\n長期方案探討：如何根治 Token 過期問題 緊急處理後，我開始思考長期解決方案。核心問題是：\n如何在 Token 過期時，讓使用者無感知地繼續使用 App？\n技術方案決策樹 方案一：Refresh Token 機制（業界標準） 運作原理 Token 生命週期狀態圖 Strapi 內建支援 令我驚喜的是，Strapi v5.15.1 已經內建完整的 Refresh Token 系統！\n// config/plugins.ts \u0026#34;users-permissions\u0026#34;: { config: { jwtManagement: \u0026#39;refresh\u0026#39;, // ✨ 啟用 refresh token 模式 sessions: { accessTokenLifespan: 3600, // 1 小時 maxRefreshTokenLifespan: 2592000, // 30 天 idleRefreshTokenLifespan: 604800, // 7 天 httpOnly: true, // HTTP-Only Cookie } } } 方案評估 評估項目 評分 說明 安全性 ⭐⭐⭐⭐⭐ 符合 OAuth 2.0 標準 使用者體驗 ⭐⭐⭐⭐⭐ 完全無感知 第三方登入支援 ⭐⭐⭐⭐⭐ LINE/Google 完全支援 實作難度 ⭐⭐⭐ 需要前後端協同開發 維護成本 ⭐⭐⭐⭐ 官方支援，穩定可靠 方案二：Token 快過期前自動更新 核心概念 在 Token 快過期前（例如剩餘 1 天），App 在背景自動用儲存的密碼重新登入，取得新 Token。\nToken 自動更新流程 完整生命週期 第 1 天：登入成功，Token 有效期 30 天 └─\u0026gt; 加密儲存憑證 └─\u0026gt; 啟動定時檢查 第 2-28 天：正常使用 └─\u0026gt; 每小時檢查：剩餘天數 \u0026gt; 1 天 ✅ 第 29 天凌晨 3:00： └─\u0026gt; 檢查：剩餘天數 \u0026lt; 1 天 ⚠️ └─\u0026gt; 背景自動登入 └─\u0026gt; 更新 Token（重新計時 30 天） └─\u0026gt; 使用者完全無感知 ✅ 第 30-58 天：繼續正常使用... 使用者控制權 App 應該提供「記住我」選項，尊重使用者隱私：\n┌─────────────────────────────┐ │ 登入 │ ├─────────────────────────────┤ │ 帳號: user@example.com │ │ 密碼: ******** │ │ │ │ ☑ 記住我（自動保持登入） │ │ │ │ [ 登入 ] │ └─────────────────────────────┘ 方案評估 評估項目 評分 說明 安全性 ⭐⭐⭐ 需加密儲存密碼 使用者體驗 ⭐⭐⭐⭐ 幾乎無感知（背景更新） 第三方登入支援 ⭐ LINE/Google 無法使用 實作難度 ⭐⭐⭐⭐ 純前端實作，較簡單 維護成本 ⭐⭐⭐ 需自行維護加密邏輯 兩種方案深度比較 安全性對比 情境比較 情境一：使用者變更密碼 情境二：帳號安全問題 情境 自動重新登入 Refresh Token 帳號被盜 ⚠️ 變更密碼後駭客仍可短暫使用 ✅ 立即撤銷所有 token 強制登出所有裝置 ❌ 無法實現 ✅ 撤銷 refresh token 異常登入偵測 ⚠️ 難以實現 ✅ 可追蹤 token 使用記錄 情境三：第三方登入 LINE 登入流程： 自動重新登入方案： 使用者點擊「LINE 登入」 ↓ 沒有帳號密碼可以儲存 ❌ ↓ Token 過期時： → 只能重新點擊「LINE 登入」 → 使用者需要手動操作 Refresh Token 方案： 使用者點擊「LINE 登入」 ↓ 返回 Access Token + Refresh Token ✅ ↓ Token 過期時： → 自動使用 Refresh Token 更新 → 使用者完全無感知 最終比較表 項目 自動重新登入 Refresh Token 優勢方 使用者體驗 🟢 幾乎無感知 🟢 完全無感知 Refresh Token 安全性 🟡 需儲存密碼 🟢 只儲存可撤銷 token Refresh Token 第三方登入 🔴 不支援 🟢 完全支援 Refresh Token 密碼變更 🔴 會失效 🟢 不受影響 Refresh Token 遠端登出 🔴 無法實現 🟢 支援 Refresh Token 後端修改 🟢 不需要 🟡 需要啟用 自動重新登入 實作難度 🟢 簡單 🟡 中等 自動重新登入 開發時間 🟢 1-2 天 🟡 3-5 天 自動重新登入 維護成本 🟡 中等 🟢 低（官方支援） Refresh Token 符合標準 🟡 非標準做法 🟢 OAuth 2.0 標準 Refresh Token 勝負分數：Refresh Token 8 勝，自動重新登入 2 勝\n我的最終決策：分階段實施 經過深思熟慮，我決定採取兩階段策略：\n為什麼選擇 Refresh Token？ 長期來看更安全、更穩定\n符合 OAuth 2.0 標準 可撤銷、可追蹤 支援所有登入方式\n帳號密碼：✅ LINE 登入：✅ Google 登入：✅ 為未來功能打下基礎\n多裝置管理 遠端登出 安全性通知 使用者體驗最佳\n完全無感知的 token 更新 密碼變更不影響使用 實作計畫 Week 1：後端開發 啟用 Strapi Refresh Token 配置 更新自訂登入端點（LINE/Google） 實作 /auth/refresh endpoint 測試 token 刷新流程 Week 2：前端開發 實作 Token 管理器 實作自動刷新邏輯（快過期前主動刷新） 整合到現有認證流程 處理 refresh 失敗情境 Week 3：測試與上線 內部測試（QA 團隊） 灰度發布（10% 使用者） 監控錯誤率和使用者回饋 全量上線 寫在最後：技術債與外包管理的教訓 這次事件讓我深刻體會到幾點：\n1. 合約條款要具體，不能只寫技術名詞 問題根源：\n合約只寫：「OAuth 2.0 (for 第三方登入)」 外包商理解：只要能用第三方登入就好 實際應包含：完整的 OAuth 2.0 流程（含 Refresh Token） 教訓：\n⚠️ 不要假設外包商了解技術標準的完整定義。\n改進方向：\n原本的寫法 改進後的寫法 ❌ OAuth 2.0 ✅ OAuth 2.0 完整流程，包含：- Authorization Code Flow- Access Token + Refresh Token- Token 自動刷新機制- Token 過期處理 ❌ RESTful API ✅ RESTful API，需包含：- 分頁機制- 錯誤碼標準化- Rate Limiting- API 文件（Swagger） ❌ RWD 網頁 ✅ RWD 網頁設計：- 支援 Desktop（1920px）- 支援 Tablet（768px）- 支援 Mobile（375px）- 斷點測試報告 2. 驗收標準要包含「非功能性需求」 問題：\n驗收時測試項目： ✅ 可以登入 ✅ 可以看到資料 ✅ 可以登出 但沒測試： ❌ Token 30 天後會不會過期？ ❌ 過期後使用者體驗如何？ ❌ 有沒有自動刷新機制？ 教訓：\n⚠️ 驗收不只是測「能不能用」，還要測「用得好不好」、「用得久不久」。\n改進方向：\n驗收測試應包含： ├─ 功能測試（傳統項目） │ ├─ 登入成功 │ ├─ 登出成功 │ └─ 資料正常顯示 │ ├─ 時間測試（容易忽略！）⭐ │ ├─ Token 快過期時的行為 │ ├─ Token 過期後的處理 │ ├─ 長時間閒置後的狀態 │ └─ 模擬 30 天後的使用情境 │ ├─ 標準合規性測試（最重要！）⭐⭐ │ ├─ OAuth 2.0 是否完整實作？ │ ├─ 是否有 Refresh Token？ │ ├─ 是否符合 RFC 6749 規範？ │ └─ 請外包商提供技術文件證明 │ └─ 異常情境 ├─ 網路中斷 ├─ API 錯誤 └─ 密碼變更 3. 外包合約要有「技術標準附件」 這次事件最大的教訓是：合約條款和技術理解有落差。\n建議做法：\n附件一：技術標準清單\n## OAuth 2.0 實作標準 依據 RFC 6749 規範，本專案的 OAuth 2.0 實作應包含： ### 必須實作項目（Missing any = 驗收不通過） - [ ] Authorization Code Flow - [ ] Access Token 發放 - [ ] Refresh Token 發放 ⭐ - [ ] Token 刷新 API endpoint ⭐ - [ ] Token 自動刷新邏輯（前端）⭐ - [ ] Token 過期處理 - [ ] 錯誤處理（Invalid Token / Expired Token） ### 驗收標準 - Access Token 有效期：不超過 1 小時 - Refresh Token 有效期：至少 30 天 - Token 刷新成功率：\u0026gt; 99.9% - 使用者無感知刷新：是 ### 參考文件 - [RFC 6749 - OAuth 2.0](https://tools.ietf.org/html/rfc6749) - [OAuth 2.0 Best Practices](https://tools.ietf.org/html/rfc8725) 附件二：驗收測試腳本\n## Token 管理驗收測試 ### Test Case 1: Refresh Token 存在性 1. 使用者登入 2. 檢查回應是否包含 `refresh_token` 欄位 3. ✅ Pass: 有 / ❌ Fail: 沒有 ### Test Case 2: Token 自動刷新 1. 使用者登入 2. 等待 Access Token 過期（或手動設為過期） 3. 發送 API 請求 4. 檢查是否自動刷新（無需使用者重新登入） 5. ✅ Pass: 自動刷新 / ❌ Fail: 要求重新登入 ### Test Case 3: 長期使用情境 1. 使用者登入 2. 模擬 30 天後的情境（調整系統時間） 3. 檢查使用者是否仍能正常使用 4. ✅ Pass: 可正常使用 / ❌ Fail: 被登出 4. 後續處理：與外包商溝通策略 發現問題後的應對：\n溝通要點：\n準備證據\n✅ 合約條款截圖（「OAuth 2.0」要求） ✅ OAuth 2.0 標準文件（RFC 6749） ✅ 實際交付與標準的對比表 ✅ 使用者問題回報記錄 明確訴求\n「根據合約第 15 頁要求實作 OAuth 2.0， 而 OAuth 2.0 標準（RFC 6749）明確包含 Refresh Token 機制。 目前交付的系統缺少此功能，請於 X 日前補完。」 提供選項\n選項 A：外包商補做（最佳） 選項 B：我方自行開發，從尾款扣除相應費用 選項 C：第三方技術鑑定 保留彈性\n如果外包商確實不熟悉，可以提供技術指導 設定合理的完成時間（例如 2-3 週） 建立明確的驗收標準 5. 認證機制是基礎建設 Token 管理看似簡單，實則關係到：\n✅ 使用者體驗（會不會突然被登出） ✅ 資料安全（能否撤銷 token） ✅ 功能擴展（能否支援多裝置管理） 💡 重要觀念： 基礎建設的品質，決定了系統的天花板。\n6. 技術選型要看長遠 「自動重新登入」確實能快速解決問題，但會產生技術債：\n技術債累積過程： 第 1 個月：快速上線 ✅ 第 3 個月：發現第三方登入不支援 ⚠️ 第 6 個月：想實作多裝置管理，發現架構不支援 ❌ 第 12 個月：被迫重構整個認證系統 💸💸💸 重構成本 \u0026gt;\u0026gt; 一開始就做對的成本 ✅ 最佳實踐： 多花幾天實作正確的方案，勝過日後花數週重構。\n總結 從「會員資料不見」的問題，到發現是 Token 過期，再到翻出合約發現外包商沒做完整，這次經驗讓我學到：\n技術層面 問題表象不等於真相\n資料沒有不見，只是使用者因 Token 過期而看不到 根本原因是 Token 管理機制不完整 緊急處理要果斷\n先延長 Token 有效期止血 爭取時間規劃長期方案 長期方案要深思\n選擇 Refresh Token（業界標準） 而非自動重新登入（技術債） 管理層面（更重要！） 這不是我沒有要求，是外包商沒做\n合約明確寫了「OAuth 2.0」 OAuth 2.0 標準就包含 Refresh Token 外包商只做了一半 合約條款要展開技術細節\n不能只寫技術名詞（OAuth 2.0） 要列出具體實作項目（Refresh Token、自動刷新等） 附上技術標準文件（RFC 6749） 驗收標準要包含時間維度\n不只測「能不能用」 還要測「用得久不久」（30 天後的行為） 標準合規性測試（是否符合 OAuth 2.0 完整規範） 外包管理要有追溯機制\n發現缺失時，能回頭查證合約 收集證據（合約、標準文件、交付物） 明確訴求並提供解決選項 給其他技術主管的建議 如果你也在管理外包專案：\n✅ Do（該做的）: - 把技術標準展開成檢查清單 - 驗收時測試長期使用情境 - 保留合約解釋的證據（RFC 文件、業界標準） - 發現問題時立即溝通，不要拖延 ❌ Don\u0026#39;t（別做的）: - 假設外包商了解技術標準的完整定義 - 只測試「能登入」就算驗收通過 - 問題發生後才想起要看合約 - 自己默默修 Bug，放棄向外包商追究 希望這篇文章能幫助到：\n🎯 遇到相似 Token 問題的開發者 📋 需要管理外包專案的技術主管 📝 正在撰寫技術合約的 PM 如果有任何問題或建議，歡迎在下方留言討論！\n#JWT #OAuth2.0 #RefreshToken #外包管理 #合約管理 #技術驗收 #Strapi #技術債務 #專案管理\n","permalink":"https://peter-notes.com/posts/oauth-refresh-token-outsourcing-lessons/","summary":"\u003ch2 id=\"故事的開始會員資料集體消失事件\"\u003e故事的開始：會員資料集體消失事件\u003c/h2\u003e\n\u003cp\u003e最近收到許多會員的反映，問題驚人地一致：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e📝 「我的日記資料不見了！」\u003c/li\u003e\n\u003cli\u003e👤 「帳號被自動登出，要重新登入」\u003c/li\u003e\n\u003cli\u003e⏳「App 一直轉圈圈，什麼都看不到」\u003c/li\u003e\n\u003cli\u003e😰 「重新開啟 App 也一樣，資料都不見了」\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e更詭異的是，這些問題似乎\u003cstrong\u003e沒有規律性\u003c/strong\u003e，有的使用者正常，有的使用者卻深受其擾。身為技術負責人，這讓我立刻警覺：這不是資料遺失，而是\u003cstrong\u003e認證機制出問題了\u003c/strong\u003e。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"調查過程抽絲剝繭找出真相\"\u003e調查過程：抽絲剝繭找出真相\u003c/h2\u003e\n\u003ch3 id=\"問題時間軸\"\u003e問題時間軸\u003c/h3\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/token-refresh-mechanism-analysis-diagram-e6010921.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003ch3 id=\"第一步確認資料庫完整性\"\u003e第一步：確認資料庫完整性\u003c/h3\u003e\n\u003cp\u003e我的第一個懷疑是資料庫是否真的遺失資料。登入後台查詢，發現：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e✅ \u003cstrong\u003e關鍵發現：\u003c/strong\u003e 所有會員的日記資料都完好無缺\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e這代表問題不在資料層，而是在\u003cstrong\u003e存取權限\u003c/strong\u003e上。\u003c/p\u003e\n\u003ch3 id=\"第二步檢查使用者行為模式\"\u003e第二步：檢查使用者行為模式\u003c/h3\u003e\n\u003cp\u003e整理會員回報的時間點，我發現一個規律：\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e會員\u003c/th\u003e\n          \u003cth\u003e註冊時間\u003c/th\u003e\n          \u003cth\u003e問題發生時間\u003c/th\u003e\n          \u003cth\u003e間隔\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e會員 A\u003c/td\u003e\n          \u003ctd\u003e30 天前\u003c/td\u003e\n          \u003ctd\u003e今天\u003c/td\u003e\n          \u003ctd\u003e30 天\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e會員 B\u003c/td\u003e\n          \u003ctd\u003e29 天前\u003c/td\u003e\n          \u003ctd\u003e今天\u003c/td\u003e\n          \u003ctd\u003e29 天\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e會員 C\u003c/td\u003e\n          \u003ctd\u003e3 天前\u003c/td\u003e\n          \u003ctd\u003e正常使用\u003c/td\u003e\n          \u003ctd\u003e-\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e\u003cstrong\u003e關鍵字：30 天\u003c/strong\u003e ⚡\u003c/p\u003e\n\u003ch3 id=\"第三步檢查-jwt-token-配置\"\u003e第三步：檢查 JWT Token 配置\u003c/h3\u003e\n\u003cp\u003e翻開 Strapi 後端的配置檔，真相大白：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// Strapi 預設 JWT 設定\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"nx\"\u003ejwt\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"nx\"\u003eexpiresIn\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;30d\u0026#39;\u003c/span\u003e  \u003cspan class=\"c1\"\u003e// Token 有效期：30 天\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"問題根本原因\"\u003e問題根本原因\u003c/h3\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/token-refresh-mechanism-analysis-diagram-887ad6d2.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cblockquote\u003e\n\u003cp\u003e💡 \u003cstrong\u003e關鍵洞察：\u003c/strong\u003e 使用者以為資料不見了，實際上只是「看不到」而已。資料仍安全地存在資料庫中，只是 Token 過期導致無法存取。\u003c/p\u003e","title":"會員資料不見？這不是我沒要求，是外包商沒做完整的 OAuth 2.0"},{"content":" 前言：AI 協作開發的範式演進 在 AI 輔助開發工具快速演進的今天，我們已經從「程式碼自動補全」進化到「AI 主動協作」的階段。Claude Code 作為 Anthropic 推出的革命性開發工具，引入了兩種截然不同但互補的協作模式：Subagent-Driven Development 和 Parallel Session。\n這兩種模式不只是技術上的差異，更代表了兩種不同的開發哲學：\nSubagent-Driven：像是 pair programming，強調即時互動和快速迭代 Parallel Session：像是 CI/CD pipeline，強調自動化執行和長期任務管理 在實際使用這兩種模式數週後，我深刻體會到它們各自的優勢與適用場景。這篇文章將系統性地分享我的實戰經驗，幫助你理解何時使用哪種模式，以及如何最大化開發效率。\n核心概念：理解兩種開發模式 Subagent-Driven Development：即時協作的藝術 Subagent-Driven 的核心理念是在單一 session 內動態生成多個子代理（subagent），每個 subagent 負責特定的任務，並在完成後立即提供回饋。\n技術架構：\nSubagent 的角色與職責：\n每個 subagent 就像一個專業的工程助理，具備特定的能力：\nSubagent 類型 職責 輸出成果 Code Writer 撰寫完整的功能程式碼 可執行的程式碼檔案 Component Builder 建立元件骨架和結構 元件檔案與介面定義 Logic Implementer 實作互動邏輯與狀態管理 狀態機、事件處理器 Code Reviewer 自動進行程式碼審查 審查報告、改進建議 Test Validator 執行測試並驗證輸出 測試結果、覆蓋率報告 核心特性：\n即時回饋循環：每個 subagent 完成任務後立即提供結果，開發者可以馬上調整方向 共享上下文：所有 subagent 在同一 session 內，共享相同的專案上下文和對話歷史 快速迭代：適合需要頻繁調整的任務，如 UI 調整、API 整合、演算法優化 低延遲溝通：開發者和 AI 之間的溝通成本極低，可以隨時打斷、調整 實際工作流程範例：\n// 開發者指令：「建立一個熱力圖元件，使用 D3.js 繪製」 // Subagent 1: Component Builder // 輸出：元件骨架 const HeatmapComponent = { props: [\u0026#39;data\u0026#39;, \u0026#39;width\u0026#39;, \u0026#39;height\u0026#39;], setup(props) { // 基本結構 } } // Subagent 2: Logic Implementer // 輸出：D3.js 繪圖邏輯 import * as d3 from \u0026#39;d3\u0026#39; const drawHeatmap = (data, width, height) =\u0026gt; { const svg = d3.select(\u0026#39;#heatmap\u0026#39;) .attr(\u0026#39;width\u0026#39;, width) .attr(\u0026#39;height\u0026#39;, height) // 色階設定 const colorScale = d3.scaleSequential(d3.interpolateYlOrRd) .domain([0, d3.max(data, d =\u0026gt; d.value)]) // 繪製矩形 svg.selectAll(\u0026#39;rect\u0026#39;) .data(data) .enter() .append(\u0026#39;rect\u0026#39;) .attr(\u0026#39;x\u0026#39;, d =\u0026gt; d.x) .attr(\u0026#39;y\u0026#39;, d =\u0026gt; d.y) .attr(\u0026#39;width\u0026#39;, 20) .attr(\u0026#39;height\u0026#39;, 20) .attr(\u0026#39;fill\u0026#39;, d =\u0026gt; colorScale(d.value)) } // Subagent 3: Code Reviewer // 輸出：審查建議 /* 建議改進： 1. 色階應該使用 props 傳入，提高可重用性 2. 缺少 tooltip 互動功能 3. 需要處理資料為空的情況 */ // Subagent 4: Test Validator // 輸出：測試結果 describe(\u0026#39;HeatmapComponent\u0026#39;, () =\u0026gt; { it(\u0026#39;should render correct number of cells\u0026#39;, () =\u0026gt; { const data = [{x: 0, y: 0, value: 5}] const result = mount(HeatmapComponent, { props: { data } }) expect(result.findAll(\u0026#39;rect\u0026#39;).length).toBe(1) }) }) // ✅ All tests passed 適用場景：\n✅ 高度適合：\n互動式 UI 開發（需要視覺調整） API 整合與調試（需要即時測試） 演算法優化（需要快速驗證效能） 原型開發（需要快速嘗試不同方向） Bug 修復（需要即時驗證修復效果） ❌ 不適合：\n長時間執行的任務（如資料庫遷移） 可完全自動化的批次任務 不需要人工監督的重複性工作 需要背景執行的任務（會佔用 session） Parallel Session：自動化執行的力量 Parallel Session 代表了完全不同的協作模式：建立多條獨立的平行開發支線，每條支線自主執行任務，不需要持續的人工監督。\n技術架構：\n實作技術：Git Worktree + Superpowers\nParallel Session 的實作依賴於兩個關鍵技術：\nGit Worktree：允許同一個 repository 有多個工作目錄 Superpowers Skill：提供自動化執行框架 實際設定範例：\n# 1. 建立主要開發分支 git checkout -b feature/user-dashboard # 2. 為不同任務建立 worktree git worktree add ../user-dashboard-frontend feature/frontend git worktree add ../user-dashboard-backend feature/backend git worktree add ../user-dashboard-tests feature/tests # 3. 查看所有 worktree git worktree list # /Users/dev/project abc1234 [feature/user-dashboard] # /Users/dev/user-dashboard-frontend def5678 [feature/frontend] # /Users/dev/user-dashboard-backend ghi9012 [feature/backend] # /Users/dev/user-dashboard-tests jkl3456 [feature/tests] # 4. 使用 Superpowers 執行計畫 # 在每個 worktree 中啟動獨立的 Claude Code session # Session 1 (frontend): 專注於 React 元件開發 # Session 2 (backend): 專注於 Express API 開發 # Session 3 (tests): 專注於 Cypress E2E 測試 Superpowers 整合：執行計畫範例\n使用 superpowers:executing-plans skill 來管理 Parallel Session：\n# implementation-plan.md ## 前端開發 (Session 1) ### Task 1.1: 建立 Dashboard 佈局 - 檔案: `src/components/Dashboard.tsx` - 需求: 響應式網格佈局，支援 2x2 卡片 - 驗證: 在 1920x1080 和 375x667 解析度下測試 ### Task 1.2: 實作使用者統計卡片 - 檔案: `src/components/UserStatsCard.tsx` - 需求: 顯示總使用者數、活躍使用者、新註冊數 - API: `GET /api/stats/users` - 驗證: Mock API 回應測試 ### Task 1.3: 建立圖表元件 - 檔案: `src/components/UsageChart.tsx` - 需求: 使用 Chart.js 繪製過去 7 天使用量 - 驗證: 測試資料更新動畫 --- ## 後端開發 (Session 2) ### Task 2.1: 設計資料庫 Schema - 檔案: `prisma/schema.prisma` - 需求: User, Session, Activity 三個 model - 驗證: `prisma generate` 無錯誤 ### Task 2.2: 實作統計 API - 檔案: `src/api/stats.ts` - 端點: `GET /api/stats/users` - 回應格式: `{ total: number, active: number, new: number }` - 驗證: Postman 測試所有情境 ### Task 2.3: 實作使用量查詢 - 檔案: `src/api/usage.ts` - 端點: `GET /api/usage?days=7` - 需求: 聚合過去 N 天的 activity 資料 - 驗證: 測試邊界條件（0 天、365 天） --- ## 測試開發 (Session 3) ### Task 3.1: Dashboard E2E 測試 - 檔案: `cypress/e2e/dashboard.cy.ts` - 測試: 頁面載入、卡片顯示、圖表渲染 - 驗證: CI 環境通過測試 ### Task 3.2: API 整合測試 - 檔案: `tests/integration/stats.test.ts` - 測試: API 回應格式、錯誤處理、效能 - 驗證: 所有測試覆蓋率 \u0026gt; 80% ### Task 3.3: 效能測試 - 檔案: `tests/performance/load.test.ts` - 測試: 1000 併發請求，p95 延遲 \u0026lt; 200ms - 驗證: Artillery 報告通過 使用 Claude Code 執行計畫：\n# 在 Claude Code 中使用 slash command /superpowers:execute-plan implementation-plan.md # 系統會： # 1. 解析計畫檔案 # 2. 批次 1: 執行 Task 1.1, 2.1, 3.1 (獨立任務) # 3. 報告進度並等待審查 # 4. 批次 2: 執行 Task 1.2, 2.2, 3.2 (依賴批次 1) # 5. 報告進度並等待審查 # 6. 批次 3: 執行 Task 1.3, 2.3, 3.3 (依賴批次 2) # 7. 最終整合驗證 Checkpoint 機制：\nParallel Session 的關鍵優勢是可恢復性。每個任務完成後都會建立 checkpoint：\n# .antigravity/checkpoints/task-1-1.yml task_id: \u0026#34;1.1\u0026#34; status: \u0026#34;completed\u0026#34; timestamp: \u0026#34;2025-11-30T10:30:00Z\u0026#34; files_modified: - \u0026#34;src/components/Dashboard.tsx\u0026#34; - \u0026#34;src/components/Dashboard.test.tsx\u0026#34; git_commit: \u0026#34;abc1234\u0026#34; verification: - type: \u0026#34;test\u0026#34; command: \u0026#34;npm test Dashboard\u0026#34; result: \u0026#34;passed\u0026#34; - type: \u0026#34;visual\u0026#34; screenshot: \u0026#34;.antigravity/screenshots/dashboard-1920x1080.png\u0026#34; status: \u0026#34;approved\u0026#34; 如果系統崩潰或需要中斷，可以從最近的 checkpoint 恢復：\n# 恢復執行 /superpowers:execute-plan implementation-plan.md --resume-from task-1-2 # 系統會： # 1. 載入 task-1-1 的 checkpoint # 2. 跳過已完成的任務 # 3. 從 task-1-2 繼續執行 適用場景：\n✅ 高度適合：\n大型重構專案（多個模組同時修改） 長期執行任務（資料庫遷移、大量測試） 多人協作專案（不同開發者負責不同 session） 可完全自動化的任務（不需要即時回饋） 跨領域整合（前端、後端、DevOps 並行） ❌ 不適合：\n需要頻繁調整方向的任務 高度依賴視覺回饋的 UI 開發 探索性開發（方向不明確） 簡單快速的 bug 修復 深入對比：兩種模式的技術差異 工作流程對比 技術特性比較表 面向 Subagent-Driven Parallel Session 執行模式 同步、即時互動 非同步、批次執行 上下文共享 單一 session，完全共享 多個 session，獨立上下文 回饋延遲 秒級（\u0026lt; 5 秒） 分鐘級（批次完成後） 可中斷性 高（隨時調整） 低（需等待 checkpoint） 可恢復性 低（session 關閉後遺失） 高（checkpoint 機制） 並行能力 有限（單一 session 內） 強大（多 session 並行） 適合任務數 1-5 個小任務 10-100 個大任務 人工監督 持續監督 階段性審查 Git 整合 手動 commit 自動 commit per task 測試驗證 手動觸發 自動執行 資源佔用 佔用主 session 背景執行 學習曲線 低（直覺操作） 中（需理解計畫格式） 錯誤恢復 手動重試 自動重試（可設定） 成本效益 高（快速任務） 高（長期任務） 決策流程圖：如何選擇模式？ 實戰案例：真實世界的應用 案例 1：使用 Subagent-Driven 開發互動式熱力圖 背景： 需要為資料分析儀表板開發一個互動式熱力圖元件，要求支援縮放、tooltip、色階調整等功能。\n為什麼選擇 Subagent-Driven？\n高度視覺化的任務，需要即時查看效果 色階、佈局需要多次調整 互動邏輯需要快速驗證 實際執行過程：\n步驟 1：初始元件骨架\n// 開發者指令：「建立一個 Vue 3 熱力圖元件，使用 D3.js」 // Subagent 1 輸出：基本結構 \u0026lt;template\u0026gt; \u0026lt;div ref=\u0026#34;container\u0026#34; class=\u0026#34;heatmap-container\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { ref, onMounted, watch } from \u0026#39;vue\u0026#39; import * as d3 from \u0026#39;d3\u0026#39; const props = defineProps({ data: { type: Array, required: true }, width: { type: Number, default: 800 }, height: { type: Number, default: 600 } }) const container = ref(null) onMounted(() =\u0026gt; { initHeatmap() }) const initHeatmap = () =\u0026gt; { // TODO: D3.js 繪圖邏輯 } \u0026lt;/script\u0026gt; 步驟 2：D3.js 繪圖邏輯\n// 開發者指令：「實作 D3.js 繪圖，使用 YlOrRd 色階」 // Subagent 2 輸出：完整繪圖邏輯 const initHeatmap = () =\u0026gt; { const svg = d3.select(container.value) .append(\u0026#39;svg\u0026#39;) .attr(\u0026#39;width\u0026#39;, props.width) .attr(\u0026#39;height\u0026#39;, props.height) // 計算格子尺寸 const rows = [...new Set(props.data.map(d =\u0026gt; d.row))].length const cols = [...new Set(props.data.map(d =\u0026gt; d.col))].length const cellWidth = props.width / cols const cellHeight = props.height / rows // 色階設定 const colorScale = d3.scaleSequential(d3.interpolateYlOrRd) .domain([0, d3.max(props.data, d =\u0026gt; d.value)]) // 繪製熱力圖格子 svg.selectAll(\u0026#39;rect\u0026#39;) .data(props.data) .enter() .append(\u0026#39;rect\u0026#39;) .attr(\u0026#39;x\u0026#39;, d =\u0026gt; d.col * cellWidth) .attr(\u0026#39;y\u0026#39;, d =\u0026gt; d.row * cellHeight) .attr(\u0026#39;width\u0026#39;, cellWidth) .attr(\u0026#39;height\u0026#39;, cellHeight) .attr(\u0026#39;fill\u0026#39;, d =\u0026gt; colorScale(d.value)) .attr(\u0026#39;stroke\u0026#39;, \u0026#39;#fff\u0026#39;) .attr(\u0026#39;stroke-width\u0026#39;, 1) } 步驟 3：即時調整色階\n// 開發者：「色彩太淺了，能否調整為更鮮豔的色階？」 // Subagent 3 即時調整： const colorScale = d3.scaleSequential() .interpolator(d3.interpolateRgbBasis([\u0026#39;#fee5d9\u0026#39;, \u0026#39;#fcae91\u0026#39;, \u0026#39;#fb6a4a\u0026#39;, \u0026#39;#de2d26\u0026#39;, \u0026#39;#a50f15\u0026#39;])) .domain([0, d3.max(props.data, d =\u0026gt; d.value)]) // 立即看到效果，滿意後繼續下一步 步驟 4：新增 Tooltip 互動\n// 開發者：「滑鼠移到格子上時顯示數值」 // Subagent 4 輸出：Tooltip 邏輯 const tooltip = d3.select(\u0026#39;body\u0026#39;) .append(\u0026#39;div\u0026#39;) .attr(\u0026#39;class\u0026#39;, \u0026#39;heatmap-tooltip\u0026#39;) .style(\u0026#39;position\u0026#39;, \u0026#39;absolute\u0026#39;) .style(\u0026#39;visibility\u0026#39;, \u0026#39;hidden\u0026#39;) .style(\u0026#39;background\u0026#39;, \u0026#39;#fff\u0026#39;) .style(\u0026#39;padding\u0026#39;, \u0026#39;8px\u0026#39;) .style(\u0026#39;border\u0026#39;, \u0026#39;1px solid #ddd\u0026#39;) .style(\u0026#39;border-radius\u0026#39;, \u0026#39;4px\u0026#39;) .style(\u0026#39;font-size\u0026#39;, \u0026#39;12px\u0026#39;) svg.selectAll(\u0026#39;rect\u0026#39;) // ... 前面的屬性 ... .on(\u0026#39;mouseover\u0026#39;, function(event, d) { tooltip .style(\u0026#39;visibility\u0026#39;, \u0026#39;visible\u0026#39;) .html(` \u0026lt;strong\u0026gt;Row ${d.row}, Col ${d.col}\u0026lt;/strong\u0026gt;\u0026lt;br/\u0026gt; Value: ${d.value.toFixed(2)} `) d3.select(this) .attr(\u0026#39;stroke\u0026#39;, \u0026#39;#000\u0026#39;) .attr(\u0026#39;stroke-width\u0026#39;, 2) }) .on(\u0026#39;mousemove\u0026#39;, function(event) { tooltip .style(\u0026#39;top\u0026#39;, (event.pageY - 10) + \u0026#39;px\u0026#39;) .style(\u0026#39;left\u0026#39;, (event.pageX + 10) + \u0026#39;px\u0026#39;) }) .on(\u0026#39;mouseout\u0026#39;, function() { tooltip.style(\u0026#39;visibility\u0026#39;, \u0026#39;hidden\u0026#39;) d3.select(this) .attr(\u0026#39;stroke\u0026#39;, \u0026#39;#fff\u0026#39;) .attr(\u0026#39;stroke-width\u0026#39;, 1) }) 步驟 5：Code Review 與優化\n// Subagent 5: Code Reviewer 自動執行 // 輸出審查報告： /* ✅ 程式碼品質評估： 優點： 1. 使用 Vue 3 Composition API，符合現代最佳實踐 2. D3.js 色階設定正確 3. Tooltip 互動流暢 建議改進： 1. 【效能】color scale 應該快取，避免每次 render 重新計算 2. 【可維護性】繪圖邏輯應拆分為獨立函式 3. 【響應式】缺少視窗 resize 處理 4. 【可訪問性】缺少 ARIA 標籤 5. 【類型安全】建議加入 TypeScript 類型定義 優先級排序： 🔴 高優先級：#1 效能優化、#3 響應式處理 🟡 中優先級：#2 程式碼重構 🟢 低優先級：#4 可訪問性、#5 TypeScript */ 步驟 6：快速迭代修復\n// 開發者：「先處理效能和響應式問題」 // Subagent 6 快速修復： // 1. 快取 color scale const colorScale = computed(() =\u0026gt; { return d3.scaleSequential() .interpolator(d3.interpolateRgbBasis([...colors])) .domain([0, d3.max(props.data, d =\u0026gt; d.value)]) }) // 2. 響應式處理 const handleResize = () =\u0026gt; { d3.select(container.value).select(\u0026#39;svg\u0026#39;).remove() initHeatmap() } onMounted(() =\u0026gt; { initHeatmap() window.addEventListener(\u0026#39;resize\u0026#39;, debounce(handleResize, 300)) }) onBeforeUnmount(() =\u0026gt; { window.removeEventListener(\u0026#39;resize\u0026#39;, handleResize) }) // 3. Debounce 工具函式 const debounce = (fn, delay) =\u0026gt; { let timeoutId return (...args) =\u0026gt; { clearTimeout(timeoutId) timeoutId = setTimeout(() =\u0026gt; fn(...args), delay) } } 最終成果：\n整個開發過程耗時約 45 分鐘，包含 6 輪快速迭代：\n初始骨架（5 分鐘） D3.js 繪圖（10 分鐘） 色階調整（3 分鐘） Tooltip 互動（8 分鐘） Code Review（自動，2 分鐘） 效能與響應式優化（15 分鐘） 最終測試（2 分鐘） 如果使用 Parallel Session，可能需要：\n預先撰寫詳細的實作計畫（30 分鐘） 等待自動執行完成（15 分鐘） 發現色階不滿意，修改計畫（10 分鐘） 重新執行（15 分鐘） 總計：70+ 分鐘 結論： 對於視覺化、互動式的開發任務，Subagent-Driven 的即時回饋大幅提高效率。\n案例 2：使用 Parallel Session 執行大規模 API 重構 背景： 需要將舊版 REST API 遷移到 GraphQL，涉及 50+ 個端點、資料庫 schema 調整、前端查詢改寫。\n為什麼選擇 Parallel Session？\n任務量龐大（預估 20+ 小時工作量） 可明確拆分為獨立子任務 不需要即時視覺回饋 可以背景執行，不佔用主 session 實作計畫結構：\n# graphql-migration-plan.md ## Phase 1: GraphQL Schema 設計 (Session 1) ### Task 1.1: 設計核心 Types **檔案：** `schema/types/user.graphql` **需求：** - User, Profile, Settings 類型定義 - 欄位對應到現有 Prisma schema - 加入必要的 nullable 設定 **驗證：** ```bash npm run graphql:validate Task 1.2: 設計查詢 Queries 檔案： schema/queries/user.graphql 需求：\ngetUser(id: ID!): User listUsers(limit: Int, offset: Int): [User!]! searchUsers(query: String!): [User!]! 驗證：\nSchema 編譯無錯誤 與前端需求文件比對 Task 1.3: 設計變更 Mutations 檔案： schema/mutations/user.graphql 需求：\ncreateUser(input: CreateUserInput!): User! updateUser(id: ID!, input: UpdateUserInput!): User! deleteUser(id: ID!): Boolean! 驗證：\nInput types 正確定義 Error handling 規範 Phase 2: Resolver 實作 (Session 2) Task 2.1: User Query Resolvers 檔案： src/resolvers/user.queries.ts 需求：\nexport const userQueries = { getUser: async (_parent, { id }, { prisma }) =\u0026gt; { return prisma.user.findUnique({ where: { id } }) }, listUsers: async (_parent, { limit = 10, offset = 0 }, { prisma }) =\u0026gt; { return prisma.user.findMany({ take: limit, skip: offset, orderBy: { createdAt: \u0026#39;desc\u0026#39; } }) }, searchUsers: async (_parent, { query }, { prisma }) =\u0026gt; { return prisma.user.findMany({ where: { OR: [ { name: { contains: query, mode: \u0026#39;insensitive\u0026#39; } }, { email: { contains: query, mode: \u0026#39;insensitive\u0026#39; } } ] } }) } } 驗證：\nnpm test src/resolvers/user.queries.test.ts Task 2.2: User Mutation Resolvers 檔案： src/resolvers/user.mutations.ts 需求：\n實作所有 CRUD mutations 加入輸入驗證（使用 Zod） 處理錯誤情境（重複 email、無效 ID） 驗證：\n所有測試通過 錯誤訊息清晰易懂 Task 2.3: Field Resolvers (關聯資料) 檔案： src/resolvers/user.fields.ts 需求：\nexport const userFieldResolvers = { User: { posts: async (parent, _args, { prisma }) =\u0026gt; { return prisma.post.findMany({ where: { authorId: parent.id } }) }, profile: async (parent, _args, { prisma }) =\u0026gt; { return prisma.profile.findUnique({ where: { userId: parent.id } }) } } } 驗證：\nN+1 查詢問題測試（使用 DataLoader） 效能測試（100 users 查詢 \u0026lt; 500ms） Phase 3: REST to GraphQL 橋接層 (Session 3) Task 3.1: 建立 REST 相容層 檔案： src/compat/rest-adapter.ts 需求：\n將舊版 REST 端點轉換為 GraphQL 查詢 維持相同的 response 格式（向後相容） 加入 deprecation warnings 範例：\n// 舊版: GET /api/users/:id // 新版: 內部轉換為 GraphQL query app.get(\u0026#39;/api/users/:id\u0026#39;, async (req, res) =\u0026gt; { const query = ` query GetUser($id: ID!) { getUser(id: $id) { id name email createdAt } } ` const result = await executeGraphQL(query, { id: req.params.id }) res.set(\u0026#39;X-API-Deprecated\u0026#39;, \u0026#39;true\u0026#39;) res.set(\u0026#39;X-Migration-Deadline\u0026#39;, \u0026#39;2026-03-01\u0026#39;) res.json(result.data.getUser) }) 驗證：\n所有現有整合測試仍通過 Deprecation headers 正確設定 Phase 4: 前端查詢遷移 (Session 4) Task 4.1: 建立 GraphQL Client 檔案： src/api/graphql-client.ts 需求：\n使用 Apollo Client 設定快取策略 錯誤處理與重試邏輯 驗證：\nnpm test src/api/graphql-client.test.ts Task 4.2: 遷移 User 相關查詢 檔案： src/hooks/useUser.ts Before (REST):\nconst useUser = (id: string) =\u0026gt; { return useSWR(`/api/users/${id}`, fetcher) } After (GraphQL):\nconst GET_USER = gql` query GetUser($id: ID!) { getUser(id: $id) { id name email profile { avatar bio } posts { id title } } } ` const useUser = (id: string) =\u0026gt; { return useQuery(GET_USER, { variables: { id } }) } 驗證：\n元件測試通過 無 console 錯誤 Task 4.3: 遷移 Mutation 操作 檔案： src/hooks/useUserMutations.ts 需求：\ncreateUser, updateUser, deleteUser mutations 樂觀更新（Optimistic UI） 快取自動更新 驗證：\nE2E 測試：建立 → 編輯 → 刪除流程 Phase 5: 整合測試與部署 (Session 5) Task 5.1: E2E 測試 檔案： tests/e2e/graphql-migration.spec.ts 測試情境：\nREST API 仍可正常運作（向後相容） GraphQL API 功能完整 前端使用 GraphQL 無錯誤 效能無退化（回應時間對比） 驗證：\nnpm run test:e2e Task 5.2: 效能測試 檔案： tests/performance/graphql-load.test.ts 需求：\n1000 併發 GraphQL 查詢 p95 延遲 \u0026lt; 200ms 記憶體使用穩定 驗證：\nartillery run tests/performance/graphql-load.yml Task 5.3: 文件更新 檔案： docs/api/graphql.md 需求：\n完整的 Schema 文件 查詢範例 遷移指南 Deprecation timeline 驗證：\n文件審查通過 **執行 Parallel Session：** ```bash # 在 Claude Code 中執行 /superpowers:execute-plan graphql-migration-plan.md # 系統輸出： # ✅ 計畫已解析：5 個 Phase，18 個 Tasks # 🚀 開始批次執行... # # 批次 1 (並行執行 Task 1.1, 1.2, 1.3): # - Session 1a: 設計核心 Types... ⏳ # - Session 1b: 設計查詢 Queries... ⏳ # - Session 1c: 設計變更 Mutations... ⏳ # # [10 分鐘後] # ✅ 批次 1 完成 (3/3 tasks) # 📊 Checkpoint 已建立 # # 等待審查... [開發者審查 schema 定義] # # 👍 審查通過，繼續批次 2 # # 批次 2 (並行執行 Task 2.1, 2.2, 2.3): # - Session 2a: User Query Resolvers... ⏳ # - Session 2b: User Mutation Resolvers... ⏳ # - Session 2c: Field Resolvers... ⏳ # # [20 分鐘後] # ✅ 批次 2 完成 (3/3 tasks) # ⚠️ 發現問題：Task 2.3 N+1 查詢問題 # 🔄 自動重試中... # ✅ 問題已修復（使用 DataLoader） # 📊 Checkpoint 已建立 # # ... [繼續執行剩餘批次] ... # # 🎉 所有任務完成！ # 📈 統計： # - 總任務數：18 # - 成功：18 # - 失敗後重試：1 # - 總執行時間：3.5 小時 # - 檔案修改：67 # - Git commits：18 # - 測試覆蓋率：87% 最終成果比較：\n面向 Parallel Session 傳統手動開發 Subagent-Driven 總時間 3.5 小時（自動） 20+ 小時 8+ 小時（需持續監督） 人工時間 30 分鐘（審查） 20+ 小時 8+ 小時 錯誤率 低（自動測試） 中（人為疏失） 低（即時修正） 可追蹤性 高（checkpoint） 中（手動 commit） 低（單一 session） 可恢復性 高 低 低 結論： 對於大規模、可明確定義的重構任務，Parallel Session 提供了10 倍效率提升，同時維持高品質和可追蹤性。\n案例 3：混合模式 - 最佳實踐 場景： 開發一個電商網站，包含產品目錄、購物車、結帳流程。\n策略： 使用混合模式，根據任務特性選擇不同模式。\n詳細執行策略：\nPhase 1: 基礎架構（100% Parallel Session）\n理由：基礎架構任務明確、可獨立執行，不需要視覺回饋。\n# Session 1: 資料庫 Schema prisma/schema.prisma model Product { id String @id @default(cuid()) name String price Decimal stock Int category Category @relation(fields: [categoryId], references: [id]) categoryId String } # Session 2: API 架構 src/api/routes/products.ts src/api/routes/cart.ts src/api/routes/orders.ts # Session 3: 認證系統 src/auth/jwt.ts src/auth/middleware.ts src/auth/providers/ (Google, Email) # 執行時間：2 小時（自動），人工時間：15 分鐘（審查） Phase 2: 產品目錄（60% Subagent, 40% Parallel）\n// Task 2.1: 產品列表 UI (Subagent-Driven) // 理由：需要視覺調整，色彩、間距、響應式佈局 // 開發者與 subagent 互動： // 「建立產品列表，使用 grid 佈局」 // → 查看效果 // 「卡片間距太小，改為 24px」 // → 即時調整 // 「加入 hover 效果，放大 1.05 倍」 // → 立即驗證 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;product-grid\u0026#34;\u0026gt; \u0026lt;ProductCard v-for=\u0026#34;product in products\u0026#34; :key=\u0026#34;product.id\u0026#34; :product=\u0026#34;product\u0026#34; @add-to-cart=\u0026#34;handleAddToCart\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;style scoped\u0026gt; .product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 24px; /* 即時調整後的值 */ } \u0026lt;/style\u0026gt; // Task 2.2: 產品搜尋 API (Parallel Session) // 理由：純邏輯實作，有明確規格，可自動執行 // implementation-plan.md ### Task 2.2: 產品搜尋 API **端點：** GET /api/products/search?q=keyword\u0026amp;category=id **需求：** - 全文搜尋（名稱、描述） - 分類篩選 - 價格範圍篩選 - 排序（價格、熱度、新品） - 分頁（limit, offset） **實作：** src/api/search/products.ts **測試：** - 測試各種搜尋組合 - 測試邊界條件 - 效能測試（1000+ 商品） // Task 2.3: 篩選器元件 (Subagent-Driven) // 理由：高度互動，需要即時調整 UI/UX // 開發者與 subagent 互動： // 「建立價格範圍滑桿」 // → 「顏色改為品牌色 #3B82F6」 // → 「加入即時預覽（顯示符合的商品數量）」 // → 「加入重置按鈕」 Phase 3: 購物車（70% Subagent, 30% Parallel）\n// Task 3.1: 購物車 UI (Subagent-Driven) // 理由：複雜的拖放互動、動畫效果 // 開發者與 subagent 互動： // 「實作拖放功能，可以調整商品順序」 // → 使用 Vue Draggable // 「加入刪除動畫（淡出）」 // → 調整動畫時長 // 「總價需要即時更新」 // → 驗證計算邏輯 // Task 3.2: 庫存管理邏輯 (Parallel Session) // 理由：複雜的業務邏輯，可完全自動化 ### Task 3.2: 庫存管理邏輯 **需求：** - 即時庫存檢查（防止超賣） - 預留庫存（加入購物車後鎖定 15 分鐘） - 庫存釋放（購物車過期、結帳失敗） - 庫存警告（低於 10 件時提示） **實作：** ```typescript class InventoryManager { async reserveStock(productId: string, quantity: number, userId: string): Promise\u0026lt;boolean\u0026gt; { // 1. 檢查庫存充足 // 2. 建立預留記錄 // 3. 設定 15 分鐘過期 // 4. 返回成功/失敗 } async releaseStock(reservationId: string): Promise\u0026lt;void\u0026gt; { // 1. 刪除預留記錄 // 2. 恢復庫存數量 } async commitReservation(reservationId: string): Promise\u0026lt;void\u0026gt; { // 1. 確認訂單付款 // 2. 永久扣減庫存 // 3. 刪除預留記錄 } } 測試：\n併發測試（100 人同時搶購） 過期測試（15 分鐘自動釋放） 異常測試（付款失敗恢復庫存） // Task 3.3: 價格計算顯示 (Subagent-Driven) // 理由：需要即時驗證計算邏輯和顯示效果\n// 開發者與 subagent 互動： // 「顯示小計、運費、折扣、總計」 // → 驗證計算邏輯 // 「加入動畫效果（數字滾動）」 // → 調整動畫參數 // 「折扣碼套用時高亮顯示折扣金額」 // → 調整視覺效果\n**效率對比：** | Phase | 傳統開發 | 純 Subagent | 純 Parallel | 混合模式 | |-------|---------|-----------|-----------|---------| | Phase 1 | 8 小時 | 4 小時 | 2 小時 | **2 小時** ✅ | | Phase 2 | 12 小時 | 6 小時 | 8 小時 | **4 小時** ✅ | | Phase 3 | 10 小時 | 5 小時 | 7 小時 | **3.5 小時** ✅ | | Phase 4 | 15 小時 | 7 小時 | 10 小時 | **5 小時** ✅ | | Phase 5 | 8 小時 | 5 小時 | 3 小時 | **3 小時** ✅ | | **總計** | **53 小時** | **27 小時** | **30 小時** | **17.5 小時** ✅ | **結論：** 混合模式根據任務特性選擇最適合的工具，達到**最高效率**（比傳統開發快 3 倍，比單一模式快 1.5 倍）。 --- ## 最佳實踐與進階技巧 ### Subagent-Driven 最佳實踐 #### 1. Prompt 工程技巧 **❌ 不好的 Prompt：** 改一下這個元件\n**✅ 好的 Prompt：** 請優化 ProductCard 元件：\n加入 skeleton loading 動畫（使用 Tailwind CSS） 圖片加入 lazy loading 價格顯示改為格式化（千分位逗號） 加入「加入購物車」按鈕的 hover 效果 確保響應式設計（手機版垂直排列） **原則：** - 具體列出所有需求 - 提供技術偏好（使用哪個函式庫） - 說明驗收標準 - 分點列出，方便 subagent 逐項處理 #### 2. 善用 Code Review Subagent ```javascript // 開發完成後，主動要求 code review // Prompt: 「請審查這段程式碼，重點檢查效能、安全性、可維護性」 // Subagent Code Reviewer 會輸出： /* 🔍 程式碼審查報告 效能問題： 🔴 Critical: useEffect 缺少 dependency array，造成無限渲染 🟡 Warning: 大型陣列使用 map，建議使用虛擬滾動 安全性問題： 🔴 Critical: XSS 風險 - 使用 dangerouslySetInnerHTML 未清理輸入 🟡 Warning: API key 硬編碼在前端程式碼中 可維護性問題： 🟢 Info: 函式過長（150 行），建議拆分為多個小函式 🟢 Info: 缺少 TypeScript 類型定義 建議修復順序： 1. 修復 useEffect dependency 2. 修復 XSS 漏洞 3. 移除硬編碼 API key 4. (可選) 重構函式結構 */ 3. 快速迭代循環 理想的迭代節奏：\n關鍵： 每次 prompt 只專注一個面向，保持快速反饋循環。\n4. 上下文管理技巧 Subagent-Driven 的限制是單一 session，上下文可能過大。解決方法：\n// 技巧 1: 定期總結上下文 // Prompt: 「總結目前的進度和待辦事項」 /* Subagent 輸出： ✅ 已完成： - ProductCard 元件基本結構 - 圖片 lazy loading - 響應式佈局 🔄 進行中： - 價格格式化（剩餘千分位逗號） 📋 待辦： - Skeleton loading 動畫 - 加入購物車按鈕互動 - Hover 效果 */ // 技巧 2: 使用「記憶點」 // Prompt: 「記住：這個專案使用 Tailwind CSS，色彩使用 blue-600 作為主色」 // 後續 subagent 會自動使用這些偏好 // 技巧 3: 關鍵決策寫入註解 // 請 subagent 在程式碼中加入關鍵決策的註解 /** * 價格計算邏輯 * * 決策記錄： * 1. 使用 Decimal.js 避免浮點數精度問題 * 2. 折扣優先級：會員折扣 \u0026gt; 促銷折扣 \u0026gt; 折扣碼 * 3. 運費計算：訂單 \u0026gt; $1000 免運費 */ Parallel Session 最佳實踐 1. 撰寫高品質實作計畫 計畫結構範本：\n# Task X.Y: [任務標題] ## 目標 [一句話描述任務目標] ## 檔案 **主要檔案：** `path/to/file.ts` **相關檔案：** `path/to/test.ts`, `path/to/types.ts` ## 需求 [詳細需求列表，使用項目符號] ## 技術規格 [具體的技術實作細節，可包含程式碼範例] ## 驗證條件 - [ ] 單元測試通過 - [ ] 整合測試通過 - [ ] Code coverage \u0026gt; 80% - [ ] ESLint 無錯誤 - [ ] TypeScript 編譯通過 ## 相依性 **依賴於：** Task X.Y-1, Task Z.W **被依賴於：** Task X.Y+1 ## 預估工作量 **複雜度：** 🟢 簡單 / 🟡 中等 / 🔴 複雜 **預估時間：** 30 分鐘 ## 備註 [任何額外的上下文或注意事項] 實際範例：Task 2.3 - 實作產品搜尋 API\n目標： 建立全文搜尋端點，支援多維度篩選和排序\n檔案：\n主要檔案：src/api/products/search.ts 相關檔案：src/api/products/search.test.ts, src/types/search.ts, prisma/schema.prisma 需求：\n支援全文搜尋（商品名稱、描述、標籤） 支援多維度篩選：分類、價格範圍、庫存狀態、品牌 支援排序：相關性、價格、熱度、新品 支援分頁（limit, offset） 返回符合條件的總數（total） 技術規格 - API 端點：\nGET /api/products/search Query Parameters: - q: string (搜尋關鍵字) - categoryId?: string - minPrice?: number - maxPrice?: number - inStock?: boolean - brandId?: string - sortBy?: \u0026#39;relevance\u0026#39; | \u0026#39;price_asc\u0026#39; | \u0026#39;price_desc\u0026#39; | \u0026#39;popularity\u0026#39; | \u0026#39;newest\u0026#39; - limit?: number (預設 20, 最大 100) - offset?: number (預設 0) Response: { products: Product[], total: number, limit: number, offset: number } 實作要點：\n使用 Prisma 全文搜尋功能 動態組合 where 條件 使用 index 優化查詢效能 實作相關性評分（使用 _relevance） 加入 query 快取（Redis, TTL 5 分鐘） 程式碼骨架：\nimport { prisma } from \u0026#39;@/lib/prisma\u0026#39; import { redis } from \u0026#39;@/lib/redis\u0026#39; import type { SearchParams, SearchResult } from \u0026#39;@/types/search\u0026#39; export async function searchProducts(params: SearchParams): Promise\u0026lt;SearchResult\u0026gt; { // 1. 生成快取 key const cacheKey = generateCacheKey(params) // 2. 檢查快取 const cached = await redis.get(cacheKey) if (cached) return JSON.parse(cached) // 3. 建立搜尋條件 const where = buildWhereClause(params) const orderBy = buildOrderByClause(params.sortBy) // 4. 執行查詢 const [products, total] = await Promise.all([ prisma.product.findMany({ where, orderBy, take: params.limit, skip: params.offset, include: { category: true, brand: true, images: { take: 1 } } }), prisma.product.count({ where }) ]) // 5. 組合結果 const result = { products, total, limit: params.limit, offset: params.offset } // 6. 快取結果 await redis.setex(cacheKey, 300, JSON.stringify(result)) return result } 驗證條件：\n單元測試覆蓋率 \u0026gt; 90%（基本搜尋、篩選、排序、分頁、邊界條件） 整合測試通過（真實資料庫查詢、Redis 快取機制） 效能測試：100 萬筆商品資料下查詢 \u0026lt; 100ms，併發 100 requests/sec 穩定 API 文件更新（OpenAPI spec） 相依性：\n依賴於：Task 1.2 (Product Schema), Task 1.3 (Prisma 全文搜尋 index) 被依賴於：Task 3.1 (前端搜尋元件), Task 4.2 (搜尋建議功能) 預估工作量： 🟡 中等，1.5 小時（實作 45 分鐘、測試 30 分鐘、文件 15 分鐘）\n備註：\n全文搜尋需要 PostgreSQL（使用 tsvector），MySQL 需調整為 FULLTEXT index Redis 快取 key 格式：search:products:{hash(params)} 考慮加入搜尋分析（記錄熱門搜尋關鍵字） 2. 任務拆分策略 黃金法則： 每個任務應該：\n✅ 可在 30-90 分鐘內完成 ✅ 有明確的驗證條件 ✅ 最小化相依性 ✅ 產出可獨立測試的成果 任務拆分範例：\n# ❌ 不好的拆分（任務過大） ## Task 1: 實作使用者認證系統 - 登入功能 - 註冊功能 - 密碼重置 - Email 驗證 - OAuth 整合（Google, GitHub） - JWT token 管理 - 權限控制 - 測試 # ✅ 好的拆分（細粒度任務） ## Task 1.1: JWT Token 工具函式 - 檔案: `src/auth/jwt.ts` - 功能: generateToken, verifyToken, refreshToken - 驗證: 單元測試 100% 覆蓋率 - 時間: 30 分鐘 ## Task 1.2: 使用者註冊 API - 檔案: `src/api/auth/register.ts` - 功能: 驗證輸入、hash 密碼、建立使用者 - 驗證: 整合測試（成功、重複 email、無效輸入） - 時間: 45 分鐘 ## Task 1.3: Email 驗證服務 - 檔案: `src/services/email-verification.ts` - 功能: 發送驗證信、驗證 token - 驗證: 使用 email sandbox 測試 - 時間: 60 分鐘 ## Task 1.4: 使用者登入 API - 檔案: `src/api/auth/login.ts` - 功能: 驗證憑證、生成 JWT - 依賴: Task 1.1 - 驗證: 整合測試 - 時間: 30 分鐘 ## Task 1.5: 密碼重置流程 - 檔案: `src/api/auth/reset-password.ts` - 功能: 請求重置、驗證 token、更新密碼 - 依賴: Task 1.3 - 驗證: E2E 測試完整流程 - 時間: 60 分鐘 ## Task 1.6: Google OAuth 整合 - 檔案: `src/auth/oauth/google.ts` - 功能: OAuth 流程、使用者關聯 - 驗證: 使用 Google OAuth playground 測試 - 時間: 90 分鐘 ## Task 1.7: GitHub OAuth 整合 - 檔案: `src/auth/oauth/github.ts` - 功能: OAuth 流程、使用者關聯 - 依賴: Task 1.6 (可重用 OAuth 基礎架構) - 驗證: 使用 GitHub OAuth app 測試 - 時間: 45 分鐘 ## Task 1.8: 權限控制中介層 - 檔案: `src/middleware/auth.ts` - 功能: requireAuth, requireRole - 依賴: Task 1.1 - 驗證: 單元測試 + 整合測試 - 時間: 45 分鐘 ## Task 1.9: E2E 認證測試 - 檔案: `tests/e2e/auth.spec.ts` - 功能: 完整流程測試 - 依賴: 所有前面的任務 - 驗證: Cypress 測試通過 - 時間: 60 分鐘 拆分原則：\n按功能邊界拆分：每個任務對應一個明確的功能單元 按檔案拆分：一個任務主要修改 1-3 個檔案 按測試類型拆分：單元測試任務、整合測試任務、E2E 測試任務分開 識別共享依賴：可重用的工具函式先實作 3. Checkpoint 策略 自動 Checkpoint（預設）：\n# .antigravity/config.yml checkpoint: auto: true trigger: - on_task_complete # 每個任務完成後 - on_test_pass # 測試通過後 - on_git_commit # Git commit 後 保存內容: - modified_files # 修改的檔案列表 - git_commit_hash # Git commit SHA - test_results # 測試結果 - execution_log # 執行日誌 - performance_metrics # 效能指標 手動 Checkpoint（關鍵節點）：\n# 在計畫中標記關鍵 checkpoint ## 🔖 Checkpoint Alpha: 基礎架構完成 **觸發條件：** Task 1.1-1.5 全部完成 **驗證：** - [ ] 所有 API 端點可正常呼叫 - [ ] 資料庫 migrations 成功 - [ ] 基本認證流程運作 **Review 重點：** - Schema 設計是否合理？ - API 介面是否符合前端需求？ - 安全性是否考慮周全？ --- ## 🔖 Checkpoint Beta: 核心功能完成 **觸發條件：** Task 2.1-2.8 全部完成 **驗證：** - [ ] 產品CRUD 功能完整 - [ ] 搜尋效能達標 - [ ] 購物車邏輯正確 **Review 重點：** - 效能是否符合預期？ - 錯誤處理是否完善？ - 使用者體驗是否流暢？ --- ## 🔖 Checkpoint RC: 上線前驗證 **觸發條件：** 所有任務完成 **驗證：** - [ ] E2E 測試 100% 通過 - [ ] 效能測試達標 - [ ] 安全性掃描無嚴重問題 - [ ] 文件完整 **Review 重點：** - 是否有遺漏的功能？ - 是否有技術債需要處理？ - 上線計畫是否完整？ Checkpoint 恢復：\n# 列出所有 checkpoint /superpowers:list-checkpoints # 輸出： # 🔖 Checkpoint Alpha (2025-11-30 10:00) # Status: ✅ Passed # Tasks: 1.1-1.5 (5/5 completed) # # 🔖 Checkpoint Beta (2025-11-30 14:30) # Status: ⚠️ Partial (7/8 tasks) # Failed: Task 2.7 (效能測試未達標) # # 🔖 Checkpoint RC # Status: ⏳ Pending # 從 Beta checkpoint 恢復並重試失敗任務 /superpowers:resume-from checkpoint-beta --retry-failed # 系統會： # 1. 恢復到 Beta checkpoint 的狀態 # 2. 檢查 Task 2.7 失敗原因 # 3. 調整實作（例如加入 index、優化查詢） # 4. 重新執行效能測試 # 5. 如果通過，繼續後續任務 4. 錯誤處理與自動重試 配置自動重試策略：\n# implementation-plan.yml retry_strategy: max_attempts: 3 backoff: exponential # linear, exponential, fixed retry_conditions: - test_failure - performance_threshold_not_met - compilation_error - linting_error no_retry_conditions: - manual_approval_rejected - dependency_missing - external_service_down task_specific_retry: \u0026#34;Task 2.7\u0026#34;: # 效能測試任務 max_attempts: 5 strategy: optimize_and_retry optimization_steps: - add_database_index - enable_query_cache - implement_connection_pooling - use_batch_processing - add_cdn_caching 自動優化範例：\n// Task 2.7: 產品搜尋效能優化 // 第一次嘗試：基本實作 // 結果：p95 延遲 450ms ❌ (目標 \u0026lt; 200ms) // 第二次嘗試：加入資料庫 index await prisma.$executeRaw` CREATE INDEX idx_product_search ON products USING GIN (to_tsvector(\u0026#39;english\u0026#39;, name || \u0026#39; \u0026#39; || description)); ` // 結果：p95 延遲 280ms ❌ (仍未達標) // 第三次嘗試：加入 Redis 快取 const cacheKey = `search:${hashParams(params)}` const cached = await redis.get(cacheKey) if (cached) return JSON.parse(cached) // ... 執行查詢 ... await redis.setex(cacheKey, 300, JSON.stringify(result)) // 結果：p95 延遲 180ms ✅ (達標！) // Checkpoint 記錄： // Attempt 1: Failed (450ms \u0026gt; 200ms) // Attempt 2: Failed (280ms \u0026gt; 200ms) - Added DB index // Attempt 3: Passed (180ms \u0026lt; 200ms) - Added Redis cache // Final solution: DB index + Redis cache 技術限制與挑戰 Subagent-Driven 的限制 1. 上下文視窗限制 問題： 長時間的 session 會累積大量上下文，導致效能下降或觸及 token 限制。\n解決方案：\n// 策略 1: 定期總結並重置 // 每完成一個功能模組，要求總結並開啟新 session // Prompt: 「總結目前完成的工作，列出關鍵決策和下一步」 /* 總結： ✅ 完成 ProductCard 元件（支援 lazy loading、響應式） ✅ 完成價格格式化（使用 Decimal.js） ✅ 完成 hover 效果（scale 1.05） 關鍵決策： - 使用 Tailwind CSS - 主色 blue-600 - 間距 24px - 圖片比例 4:3 下一步： - 購物車元件 - 結帳流程 */ // 策略 2: 使用「記憶檔案」 // 將關鍵決策寫入專案的 DECISIONS.md // 新 session 可以快速載入這個檔案 // DECISIONS.md /* # 專案決策記錄 ## UI/UX - 主色: #3B82F6 (blue-600) - 間距標準: 8px 為基準單位 - 圓角: 8px (卡片), 4px (按鈕) - 動畫時長: 200ms (hover), 300ms (轉場) ## 技術選型 - 狀態管理: Pinia - UI 框架: Tailwind CSS - 圖表: Chart.js - 日期: date-fns - 數字精度: Decimal.js ## API 規範 - 日期格式: ISO 8601 - 錯誤格式: { error: { code, message, details } } - 分頁: limit/offset */ // 新 session 開始時： // Prompt: 「請閱讀 DECISIONS.md，遵循這些決策」 2. 不適合長時間執行任務 問題： Subagent 需要開發者持續在場，不適合超過 1-2 小時的任務。\n解決方案： 混合模式 - 長任務切換到 Parallel Session\n# 範例：大型資料庫遷移 # 階段 1: 使用 Subagent-Driven 設計遷移腳本 # (30 分鐘，需要即時調整邏輯) Prompt: 「設計 User 資料表遷移腳本，從 MySQL 到 PostgreSQL」 → 生成遷移腳本 → 審查並調整 → 測試小量資料 # 階段 2: 切換到 Parallel Session 執行大規模遷移 # (3 小時，背景執行) /superpowers:execute-plan migration-plan.md → 系統自動執行 → 開發者可以去做其他事 → 完成後通知審查 3. 多人協作困難 問題： Subagent-Driven 是單人單 session，無法多人同時協作同一任務。\n解決方案： 使用 Parallel Session 或 Git 工作流程\nParallel Session 的限制 1. 需要詳細的前期規劃 問題： 如果實作計畫寫得不夠詳細，AI 可能無法正確執行。\n解決方案： 使用計畫範本 + AI 輔助生成計畫\n# 使用 Subagent 幫忙撰寫 Parallel Session 計畫 Prompt: 「我需要實作使用者認證系統，包含註冊、登入、OAuth。請幫我撰寫一份適合 Parallel Session 執行的實作計畫」 # Subagent 會生成詳細的 implementation-plan.md # 包含： # - 任務拆分（每個 30-90 分鐘） # - 檔案路徑 # - 程式碼範例 # - 驗證條件 # - 相依性圖 # 開發者審查並調整計畫 # 然後執行 Parallel Session 2. 難以處理需求變更 問題： 執行過程中如果需求改變，Parallel Session 難以動態調整。\n解決方案： Checkpoint 暫停 + Subagent 調整\n# 情境：正在執行 Phase 2，但 Phase 1 的設計需要調整 # 1. 暫停 Parallel Session /superpowers:pause-execution # 2. 切換到 Subagent-Driven 快速調整 Prompt: 「Phase 1 的 User Schema 需要加入 role 欄位，請幫我調整」 → 快速修改 schema → 更新 migration → 測試驗證 # 3. 更新實作計畫 # 修改 implementation-plan.md，反映新的 schema # 4. 恢復 Parallel Session /superpowers:resume-execution --update-plan → 系統重新載入計畫 → 繼續執行，使用新的 schema 3. 除錯困難 問題： 自動執行的程式碼出錯時，難以即時除錯。\n解決方案： 詳細的日誌 + 自動測試 + 人工審查點\n# 配置詳細日誌 logging: level: debug output: - console - file: .antigravity/logs/execution.log include: - executed_commands - test_output - error_stack_traces - performance_metrics - git_operations on_error: - capture_screenshot # 如果是 UI 相關 - dump_database_state # 如果是資料相關 - save_network_logs # 如果是 API 相關 - pause_execution # 暫停讓開發者介入 自動重試範例：\n// Task 執行失敗時的處理流程 async function executeTask(task: Task) { let attempt = 0 const maxAttempts = 3 while (attempt \u0026lt; maxAttempts) { try { attempt++ // 執行任務 const result = await runTask(task) // 執行測試 const testResult = await runTests(task.tests) if (testResult.passed) { // 成功，建立 checkpoint await createCheckpoint(task, result) return result } else { // 測試失敗，分析原因 const analysis = await analyzeFailure(testResult) if (analysis.canAutoFix) { // 自動修復並重試 await applyFix(analysis.suggestedFix) continue } else { // 無法自動修復，暫停並通知開發者 await pauseExecution() await notifyDeveloper({ task, attempt, error: testResult.errors, analysis }) // 等待人工介入 await waitForManualFix() continue } } } catch (error) { // 執行階段錯誤 logger.error(`Task ${task.id} attempt ${attempt} failed:`, error) if (attempt === maxAttempts) { // 最終失敗，記錄並暫停 await logFailure(task, error) await pauseExecution() throw new TaskExecutionError(task, error) } // 重試前等待（exponential backoff） await sleep(Math.pow(2, attempt) * 1000) } } } 工具與生態系統 推薦工具整合 工具類型 推薦工具 與 Subagent 整合 與 Parallel 整合 版本控制 Git + GitHub ✅ 手動 commit ✅ 自動 commit per task CI/CD GitHub Actions ✅ 手動觸發 ✅ 自動觸發 測試 Vitest, Cypress ✅ 即時執行 ✅ 批次執行 Linting ESLint, Prettier ✅ 即時檢查 ✅ pre-commit hook 類型檢查 TypeScript ✅ 即時驗證 ✅ 批次驗證 效能監控 Lighthouse CI ✅ 手動觸發 ✅ 自動執行 錯誤追蹤 Sentry ✅ 整合 ✅ 整合 專案管理 Linear, Notion 🔶 手動同步 ✅ 自動同步（MCP） Claude Code Superpowers Skills 推薦安裝的 skills：\n# 核心 skills - superpowers:brainstorming # 任務規劃前腦力激盪 - superpowers:writing-plans # 撰寫 Parallel Session 計畫 - superpowers:executing-plans # 執行 Parallel Session 計畫 - superpowers:verification-before-completion # 完成前驗證 - superpowers:code-reviewer # 程式碼審查 - superpowers:test-driven-development # TDD 工作流程 - superpowers:systematic-debugging # 系統化除錯 # 進階 skills - superpowers:using-git-worktrees # Git worktree 管理 - superpowers:subagent-driven-development # Subagent 最佳實踐 - superpowers:dispatching-parallel-agents # 並行代理管理 總結與決策指南 快速決策表 你的情境 推薦模式 原因 🎨 開發互動式 UI Subagent-Driven 需要即時視覺回饋 🔧 修復單一 Bug Subagent-Driven 快速迭代驗證 📦 大型重構（10+ 檔案） Parallel Session 可明確拆分任務 🗄️ 資料庫遷移 Parallel Session 長時間背景執行 🧪 撰寫測試套件 Parallel Session 可批次執行 🎯 探索性開發 Subagent-Driven 方向尚未明確 📚 撰寫技術文件 Parallel Session 可完全自動化 ⚡ 效能優化 Subagent-Driven 需要即時監控調整 🔐 實作認證系統 Parallel Session 有標準實作流程 🎭 設計原型 Subagent-Driven 需要快速嘗試 🏗️ 建立專案骨架 Parallel Session 可用範本自動化 🐛 除錯複雜問題 Subagent-Driven 需要互動式調查 📊 資料分析腳本 Parallel Session 可自動執行驗證 🎮 遊戲邏輯開發 Subagent-Driven 需要即時測試體驗 🌐 API 整合 混合模式 設計用 Subagent，批次實作用 Parallel 核心建議 1. 從 Subagent-Driven 開始，必要時切換到 Parallel Session\n大多數任務一開始方向不夠明確，使用 Subagent-Driven 快速探索和驗證。當設計穩定後，切換到 Parallel Session 大規模執行。\n2. 投資時間撰寫高品質的實作計畫\n對於 Parallel Session，詳細的計畫是成功的關鍵。花 30 分鐘撰寫計畫，可以節省 3 小時的執行時間。\n3. 建立 Checkpoint 文化\n無論哪種模式，都要養成建立 checkpoint 的習慣：\nSubagent: 每完成一個功能模組就總結 Parallel: 在關鍵節點設定 manual checkpoint 4. 善用混合模式\n不要拘泥於單一模式。根據任務特性靈活切換，發揮兩種模式的互補優勢。\n5. 持續學習和調整\n這兩種模式都還在快速演進。持續關注社群最佳實踐，調整自己的工作流程。\n參考資源 官方文件 Claude Code 官方文件 Superpowers Skills 儲存庫 Claude Code 社群論壇 延伸閱讀 AI 代理開發的未來趨勢 Parallel Session 技術架構深度解析 Git Worktree 最佳實踐 社群資源 Claude Code Discord 社群 Superpowers Cookbook 寫作時間： 2025-11-30 最後更新： 2025-11-30\n","permalink":"https://peter-notes.com/posts/2025-11-30-subagent_parallel_session/","summary":"\u003c!--\n═══════════════════════════════════════════════════════════════════\n  Mermaid 配色變數表\n\n  如需統一修改顏色，請使用全域搜尋替換對應的色碼\n  例如：搜尋 \"#ff69b4\" 替換為 \"#your-new-color\"\n═══════════════════════════════════════════════════════════════════\n\n【主要配色】\n  • Subagent-Driven (粉色系)    → #ff69b4\n  • Parallel Session (綠色系)   → #0a5317ff\n  • 完成/成功 (藍色系)          → #114762ff\n  • Checkpoint/審查 (紅色系)    → #3c1010ff\n\n【使用位置統計】\n  #114762ff (淺藍)  - 主系統、開發計畫、開發者、完成狀態\n  #ff69b4 (淺粉)  - 即時回饋、Subagent 節點\n  #0a5317ff (淺綠)  - 任務完成、Parallel Session、部署\n  #3c1010ff (淺紅)  - Checkpoint 節點\n\n【修改建議】\n  1. 保持色調一致性（淺色系 vs 深色系）\n  2. 確保文字對比度足夠（可讀性）\n  3. 考慮色盲友善設計\n\n═══════════════════════════════════════════════════════════════════\n--\u003e\n\u003ch2 id=\"前言ai-協作開發的範式演進\"\u003e前言：AI 協作開發的範式演進\u003c/h2\u003e\n\u003cp\u003e在 AI 輔助開發工具快速演進的今天，我們已經從「程式碼自動補全」進化到「AI 主動協作」的階段。Claude Code 作為 Anthropic 推出的革命性開發工具，引入了兩種截然不同但互補的協作模式：\u003cstrong\u003eSubagent-Driven Development\u003c/strong\u003e 和 \u003cstrong\u003eParallel Session\u003c/strong\u003e。\u003c/p\u003e","title":"Subagent-Driven 與 Parallel Session：AI 協作開發的兩種典範"},{"content":" 圖片來源：Google Antigravity 官方部落格\n前言：當 AI 從助手變成隊友 2025 年 11 月，Google 悄悄推出了一款顛覆性的開發工具 —— Antigravity。這不是又一個「AI 程式碼補全工具」，而是一個徹底改變我們思考軟體開發方式的平台。如果說 GitHub Copilot 和 Cursor 是你的智慧助手，那麼 Antigravity 就是一支能夠自主規劃、執行和驗證的 AI 團隊。\n在深入研究了官方文件、實際案例和社群回饋後，我想分享這個「代理優先」（Agent-First）開發平台的核心理念、創新功能，以及它目前仍面臨的挑戰。\n核心概念：什麼是「代理優先」開發？ 傳統 AI 輔助 vs. 代理優先開發 在傳統的 AI 輔助編碼中，開發者仍然是主角：你逐行寫程式碼，AI 只是在旁邊提供自動完成建議。但 Antigravity 提出了一個全新的工作模式：\n你不再是程式碼的執行者，而是任務的指揮官。\n這個轉變體現在：\n傳統模式：「我要在第 42 行加一個函數\u0026hellip;」（微觀管理） Antigravity 模式：「建立一個包含搜尋功能的活動網站」（任務導向） AI 代理會：\n分析需求並制定計劃 自主執行所有編碼工作 測試應用程式並驗證功能 產生可審查的成果文件（Artifacts） 三個核心介面：Mission Control 架構 Antigravity 設計了三個相互配合的工作介面：\nEditor 與 Agent Manager 的雙介面架構\n1. Agent Manager（非同步任務中心） 這是你的「任務控制中心」，可以：\n同時管理多個 AI 代理 查看所有代理的工作進度 審查計劃和成果文件 追蹤歷史對話 關鍵特性：多代理協作 —— 你可以讓一個代理研究 API 文件，同時另一個代理開發前端介面。\nAgent Manager 的任務控制中心\nAgent Manager 各個功能區域說明\n圖中標註說明：\nInbox - 追蹤所有對話的集中位置，顯示正在進行的任務狀態與生成的工件 Start Conversation - 啟動新對話的按鈕，直接進入提示輸入區域 Workspaces - 管理多個工作區，可隨時新增或切換不同專案環境 Playground - 臨時測試空間，可先進行對話後再轉換為具有檔案控制的正式工作區 Editor View - 切換至編輯器介面以檢視工作區資料夾、編輯檔案或提供行內指導 Browser - Chrome 瀏覽器整合功能，允許代理執行網頁互動、導航與資料擷取任務 Inbox 功能讓你追蹤所有歷史對話\n2. Editor（同步程式碼編輯器） 基於 VS Code 的熟悉介面，但增強了：\n選取程式碼後直接下達指令 即時看到 AI 的修改 保留傳統編輯能力 快速切換鍵：Cmd + E（Mac）/ Ctrl + E（Windows/Linux） Editor 顯示 Flask 專案的程式碼結構\n3. Browser（自動化驗證） 這是 Antigravity 最獨特的創新：內建瀏覽器自動化。\n代理可以：\n自動啟動應用程式 點擊、滾動、輸入資料 擷取螢幕截圖和錄影 讀取 console 錯誤 驗證程式碼真的有效 \u0026ldquo;代理不只是寫程式碼，它們還會執行程式碼。\u0026rdquo; —— Google Antigravity 團隊\n創新功能解析 1. Artifacts 系統：可驗證的成果文件 傳統 AI 工具的痛點是「信任差距」—— 你不確定 AI 是否真的完成了任務。Antigravity 透過 Artifacts 解決這個問題。\nArtifacts 側邊欄顯示代理產生的所有成果文件\nArtifacts 類型包括：\n任務清單和計劃：執行前的詳細規劃 程式碼差異（Diffs）：逐行顯示修改內容 螢幕截圖：UI 狀態的視覺證明 瀏覽器錄影：功能運作的影片證明 測試結果：結構化的測試報告 實作計畫：技術架構決策文件 任務計劃 Artifact 展示代理如何分解複雜任務\nImplementation Plan 顯示技術實作的詳細步驟\n測試結果 Artifact 顯示所有測試的執行狀況\n更棒的是，你可以像在 Google Docs 上一樣對這些成果文件加註解，AI 代理會根據你的回饋進行調整，而不是從頭來過。\n2. 瀏覽器子代理：自動化測試的革命 當 AI 代理需要驗證網頁應用時，它會召喚專門的「瀏覽器子代理」，這個子代理擁有：\n點擊元素 滾動頁面 輸入文字 讀取 Console 日誌 擷取 DOM 結構 錄影整個操作過程 代理請求設定瀏覽器以進行自動化測試\n安裝 Antigravity Chrome 擴充功能以啟用瀏覽器自動化\n代理正在瀏覽器中自動導航和測試應用\n實際案例： 當你要求代理建立一個 Pomodoro 計時器應用，代理會：\n開發 UI 和計時邏輯 啟動瀏覽器並測試功能 錄製使用過程 根據視覺結果進行迭代改進 提供影片證明功能正常運作 這是其他 IDE 無法做到的 —— 大多數工具只能寫程式碼，但不會「親自」測試。\n3. 知識庫學習系統：組織智慧的累積 Antigravity 有一個隱藏但強大的功能：漸進式學習。\n工作原理：\n代理儲存成功的解決方案到知識庫 記錄特定專案的程式碼模式 保存重複任務的詳細步驟 未來的代理可以查詢這個知識庫 重用成功的方法，而不是重新摸索 隨著時間推移，這個知識庫會成為你的組織累積智慧，包含：\n特定程式碼片段 成功的工作流程 架構模式 專案特定的偏好 4. MCP（Model Context Protocol）：外部整合 MCP 是連接 AI 代理與外部服務的標準協議。你可以：\n連接資料庫（MongoDB、Supabase） 整合專案管理工具（Linear、Notion） 整合 GitHub repository 使用 Google 的多媒體服務（Imagen、Veo、Chirp 3 HD） 設定 MCP Server 範例（Python）：\nfrom mcp import Server mcp = Server(\u0026#34;Hello World\u0026#34;) @mcp.tool() def greet_user(name: str) -\u0026gt; str: \u0026#34;\u0026#34;\u0026#34;Greet a user by name\u0026#34;\u0026#34;\u0026#34; return f\u0026#34;Hello, {name}! Welcome to MCP.\u0026#34; if __name__ == \u0026#34;__main__\u0026#34;: mcp.run(transport=\u0026#34;stdio\u0026#34;) 實戰案例：AI 代理能做什麼？ 案例 1：新聞摘要擷取器 任務：訪問多個網站並整理資訊\n輸入新聞擷取任務的指令\n代理執行：\n啟動瀏覽器 導航到指定 URL 擷取並組織資料 產生截圖和影片 回傳結構化摘要 代理自動導航到 Google News 並擷取資料\n完整的新聞摘要結果，包含結構化資訊\n成果：完全自動化的網頁爬蟲和資料整理。\n案例 2：Flask 活動網站 需求：\n顯示活動詳情和時程表 多講者技術演講 搜尋功能（依分類/講者/標題） 午休時段 虛擬資料和文件 自動啟動伺服器 代理工作流程：\n分析需求 檢查專案結構 安裝 Flask 和相依套件 建立檔案組織 實作所有功能 產生文件 啟動開發伺服器 進行自動化測試 代理建立並啟動的 Flask 活動網站\n代理實作的新增演講功能介面\n交付成果：\n任務分解文件 技術實作計畫 完成摘要 運作中應用的截圖 功能展示影片 案例 3：Pomodoro 計時器應用 任務：建立一個美觀、平靜的 Pomodoro 計時器\n代理規劃 Pomodoro 計時器的實作步驟\n代理執行：\n設計美觀平靜的 UI 實作計時器邏輯 通過瀏覽器自動化測試 根據視覺結果迭代改進 產生驗證媒體 代理建立的 Pomodoro 計時器介面\n完成的 Pomodoro 計時器應用，具備完整功能\n案例 4：複雜的 iPhone UI 應用 有使用者分享了一個驚人的案例：\n指令：\n\u0026ldquo;做一個酷炫的 iPhone UI，我可以在手機上用 Spotify 聽音樂，然後在背景玩 Flappy Bird，在這些應用之間切換，還可以用 Instagram Reels\u0026hellip;\u0026rdquo;\n代理交付：\n逼真的 iPhone 外框和主畫面 作業系統狀態管理 Instagram Reels（可垂直滾動的影片動態） Spotify 音樂播放器與音訊播放 Flappy Bird 遊戲（HTML5 Canvas 含物理引擎） 流暢的應用切換 響應式設計 時間：根據使用者回報，幾秒鐘內完成。\n安裝與配置 系統需求 macOS：\nmacOS 12+ 僅支援 Apple Silicon（M1/M2/M3/M4） 不支援 Intel 晶片 Windows：\nWindows 10 64-bit 或更新版本 x64 或 ARM64 Linux：\nglibc ≥ 2.28 Ubuntu 20+、Debian 10+、Fedora 36+、RHEL 8+ 安裝方法 macOS：\nbrew install antigravity Linux (Debian/Ubuntu)：\nsudo mkdir -p /etc/apt/keyrings curl -fsSL https://us-central1-apt.pkg.dev/doc/repo-signing-key.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/antigravity-repo-key.gpg echo \u0026#34;deb [signed-by=/etc/apt/keyrings/antigravity-repo-key.gpg] https://us-central1-apt.pkg.dev/projects/antigravity-auto-updater-dev/ antigravity-debian main\u0026#34; | sudo tee /etc/apt/sources.list.d/antigravity.list sudo apt update \u0026amp;\u0026amp; sudo apt install antigravity Windows： 從 antigravity.google/download 下載 .exe 安裝程式\n初始設定（7 個步驟） Antigravity 的初始設定歡迎畫面\n下載並啟動（首次啟動需 2-3 分鐘） 選擇設定流程（全新開始或匯入 VS Code/Cursor 設定） 選擇編輯器主題 配置執行策略（Terminal 和 Review） 配置編輯器偏好 使用個人 Gmail 帳號登入（重要：不支援 Google Workspace） 接受服務條款 三種開發模式 配置代理的自主性等級和執行策略\nAgent-driven development（自動駕駛）：AI 完全自主處理 Review-driven development：重大操作前請求批准 Agent-assisted development（推薦）：自動化與人工控制的平衡 配置審查策略，決定何時需要人工批准\nTerminal 執行策略 Off：除白名單外不自動執行 Auto：代理決定何時請求權限（推薦） Turbo：除黑名單外全部執行 工作區選擇 選擇專案工作區或使用 Playground 進行實驗\n技術規格與優勢 主要 AI 模型 選擇 AI 模型：Gemini 3 Pro、Claude Sonnet 4.5 或 GPT-4o\nGemini 3 Pro（預設）：\nGoogle 最新模型 Terminal-Bench 2.0 得分：54.2% 超大 context window，可讀取整個程式庫 每 5 小時刷新配額 替代模型：\nClaude Sonnet 4.5（Anthropic） GPT-4o（OpenAI） GPT-OSS（OpenAI 開源模型） 切換規劃模式（Planning Mode）或快速模式（Fast Mode）\n競爭優勢（相較於 Cursor IDE） 根據官方資料和使用者報告：\n重構準確度：94% vs. 競爭對手 ~78% 速度：大型 repository（10 萬行以上）快 40% API 測試：內建自動化驗證 Context Window：Gemini 3 的超大 context，支援 monorepo 規模的重構 Antigravity vs. Cursor 對比表 面向 Antigravity Cursor 哲學 代理優先，自主執行 AI 輔助，開發者驅動 工作流程 框架任務，審查成果 Composer + 行內建議 瀏覽器測試 內建自動化 + 錄影 需手動測試 穩定性 Preview 階段，有問題 成熟、可靠 自主性 多步驟規劃/執行 主要是程式碼建議 多代理 支援並行代理 不支援 成果文件 結構化交付物 標準輸出 模型 Gemini 3、Claude、GPT 單一主要模型 定價 免費（預覽階段） 付費訂閱 最適用於 全新專案、實驗 日常重構、增量編輯 最佳實踐建議 Prompt 工程 以目標為導向：描述想要的結果，而非逐步指示 清晰聚焦：避免在單一 prompt 中混合無關目標 提供詳細規格：越詳細，代理的規劃越好 使用自然語言：不需要技術術語 範例：\n❌ 差：「在第 42 行加一個函數處理使用者登入」 ✅ 好：「建立一個使用者認證系統，支援 email/密碼登入，包含表單驗證和錯誤處理」 成果文件管理 批准前務必審查任務清單和計劃 重大變更前系統性檢查成果文件 使用註解功能提供指導 檢視截圖和錄影以確認功能 工作流程組織 不同任務使用不同資料夾 正式專案前先在 Playground 實驗 利用 Inbox 追蹤和重訪對話 啟用自主瀏覽器驗證 多代理使用 為不同面向部署專門代理（前端、後端、研究） 對獨立任務並行運行代理 透過 Mission Control 儀表板監控進度 誠實面對：當前的限制與挑戰 關鍵問題（截至 2025 年 11 月） 1. 登入和認證問題 許多使用者首次啟動無法登入 卡在初始設定畫面 Google Workspace 帳號不相容 需切換到個人 Gmail 解決方法：僅使用個人 Gmail 帳號\n2. 配額快速耗盡 帳號在密集使用約 20 分鐘後耗盡配額 部分使用者單一查詢後就達到限制 無法購買額外配額 配額每 5 小時刷新但不足以進行嚴肅工作 解決方法：當模型暫停時，輸入「continue」繼續任務\n3. 模型提供商過載 頻繁出現「model provider overload」錯誤 「Agent taking unexpectedly long to load」 「Agent terminated due to error」 代理無限轉圈後崩潰 原因：發布階段的擴展挑戰\n4. 工作流程鎖定 規劃文件儲存在專有的「brain」目錄 不在專案目錄中 無法切換回 VS Code 而不失去上下文 供應商鎖定疑慮 5. 配額系統不透明 配額管理嚴格且不透明 沒有明確的剩餘配額指示 限制觸發不可預測 達到限制時的介面錯誤 6. 穩定性問題 Preview 階段的不穩定 延長使用時會崩潰 不如成熟競爭者（Cursor）可靠 感覺像「20% 時間專案」而非精雕細琢的產品 使用者情緒分析 根據社群媒體分析（X 平台，2025 年 11 月 18-19 日）：\n70% 正面回饋 30% 負面回饋 正面評論：\n「流暢且直觀的工作流程」 「幾分鐘內建立完整應用」 「瀏覽器整合是遊戲改變者」 「免費使用很慷慨」 負面評論：\n「複雜任務處理不佳」 「UI 相比競爭者過於複雜」 「登入問題阻礙使用」 「配額系統令人沮喪」 隱私與安全性 重要警告：\n程式碼會被處理以提供 AI 協助 不會用於訓練模型（Google 隱私標準） 已知安全限制記載在使用條款中 風險包括：資料外洩、惡意程式碼執行 不建議用於敏感/機密專案（預覽階段） 企業功能（未來）：\nSOC 2 合規 ISO 27001 認證 FedRAMP 授權 租戶隔離環境 推薦使用情境 目前最適合 ✅ 全新的 Web 應用開發 ✅ 實驗性專案 ✅ 學習代理工作流程 ✅ 快速原型開發 ✅ 教育目的\n目前不建議 ❌ 生產應用程式 ❌ 敏感/機密程式碼 ❌ 作為日常主力 IDE（尚未） ❌ 企業工作（Workspace 支援待定） ❌ 時間敏感專案\n評論者建議 現狀評估：\n由於關鍵錯誤，尚未準備好投入生產 更適合實驗而非生產工作 展現潛力但需要顯著改進 需要解決登入、配額、安全性和鎖定問題 許多進階使用者保留兩者：\nCursor 用於日常工作 Antigravity 用於實驗性功能 未來展望 即將推出的功能 原生行動模擬器整合 本地 WebGPU 推論（隱私敏感程式碼） 協作編輯（AI 調解） Gemini 3.5（2026 Q1）含 200 萬 token context window Google Workspace 支援 生產穩定性改進 強化配額系統 結語：典範轉移的前夜 Google Antigravity 不是一個完美的產品，但它代表了一個重要的方向：從 AI 輔助編碼到 AI 自主開發。\n這是一次大膽的實驗 Antigravity 的核心創新 —— 多代理協作、瀏覽器自動化驗證、可審查的成果文件 —— 都指向一個未來：開發者不再是程式碼的執行者，而是系統架構的設計師和任務的指揮官。\n目前的現實 然而，作為一個 Preview 產品，它仍有許多需要克服的障礙：\n登入問題讓部分使用者無法使用 配額限制讓密集使用變得困難 穩定性不如成熟競爭者 供應商鎖定的疑慮尚未解決 給開發者的建議 如果你是早期採用者：\n用個人 Gmail 帳號註冊 在非關鍵專案上實驗 善用免費配額學習代理工作流程 回報錯誤幫助改進產品 如果你需要穩定的生產工具：\n暫時繼續使用 Cursor 或其他成熟工具 觀察 Antigravity 的發展 等待 Workspace 支援和穩定性改進 最後的思考 Antigravity 讓我們窺見了 AI 開發的未來。當配額、穩定性和整合問題解決後，這個「代理優先」的願景可能真正改變我們建構軟體的方式。\n目前，它是一個充滿潛力的實驗。未來，它可能成為新的標準。\n現在是關注和學習的最佳時機，但可能還不是全面採用的時候。\n參考資源 官方資源：\n主站：https://antigravity.google 文件：https://antigravity.google/docs/get-started 下載：https://antigravity.google/download 使用案例：https://antigravity.google/use-cases 學習資源：\nGoogle Codelabs 教學：Getting Started with Google Antigravity YouTube 頻道：@googleantigravity 社群討論：\nHacker News：討論串 Google 開發者部落格：Build with Google Antigravity 圖片來源 本文所有截圖均來自 Google Antigravity 官方資源：\nGoogle Antigravity 官方部落格：Build with Google Antigravity Google Codelabs 教學：Getting Started with Google Antigravity 撰寫日期：2025 年 11 月 版本：基於 Antigravity Public Preview (November 2025)\n這篇文章基於 Google Antigravity 官方文件、Google Codelabs 教學、開發者部落格，以及社群回饋的綜合整理。所有截圖均為官方提供的教學材料。由於產品仍在 Preview 階段，部分功能和限制可能在未來版本中改變。\n","permalink":"https://peter-notes.com/posts/google-antigravity-review/","summary":"\u003cp\u003e\u003cimg alt=\"Google Antigravity Hero\" loading=\"lazy\" src=\"/images/antigravity/official-blog/hero.png\"\u003e\n\u003cem\u003e圖片來源：Google Antigravity 官方部落格\u003c/em\u003e\u003c/p\u003e\n\u003ch2 id=\"前言當-ai-從助手變成隊友\"\u003e前言：當 AI 從助手變成隊友\u003c/h2\u003e\n\u003cp\u003e2025 年 11 月，Google 悄悄推出了一款顛覆性的開發工具 —— \u003cstrong\u003eAntigravity\u003c/strong\u003e。這不是又一個「AI 程式碼補全工具」，而是一個徹底改變我們思考軟體開發方式的平台。如果說 GitHub Copilot 和 Cursor 是你的智慧助手，那麼 Antigravity 就是一支能夠自主規劃、執行和驗證的 AI 團隊。\u003c/p\u003e\n\u003cp\u003e在深入研究了官方文件、實際案例和社群回饋後，我想分享這個「代理優先」（Agent-First）開發平台的核心理念、創新功能，以及它目前仍面臨的挑戰。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"核心概念什麼是代理優先開發\"\u003e核心概念：什麼是「代理優先」開發？\u003c/h2\u003e\n\u003ch3 id=\"傳統-ai-輔助-vs-代理優先開發\"\u003e傳統 AI 輔助 vs. 代理優先開發\u003c/h3\u003e\n\u003cp\u003e在傳統的 AI 輔助編碼中，開發者仍然是主角：你逐行寫程式碼，AI 只是在旁邊提供自動完成建議。但 Antigravity 提出了一個全新的工作模式：\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e你不再是程式碼的執行者，而是任務的指揮官。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e這個轉變體現在：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e傳統模式\u003c/strong\u003e：「我要在第 42 行加一個函數\u0026hellip;」（微觀管理）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eAntigravity 模式\u003c/strong\u003e：「建立一個包含搜尋功能的活動網站」（任務導向）\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eAI 代理會：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e分析需求並制定計劃\u003c/li\u003e\n\u003cli\u003e自主執行所有編碼工作\u003c/li\u003e\n\u003cli\u003e測試應用程式並驗證功能\u003c/li\u003e\n\u003cli\u003e產生可審查的成果文件（Artifacts）\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch3 id=\"三個核心介面mission-control-架構\"\u003e三個核心介面：Mission Control 架構\u003c/h3\u003e\n\u003cp\u003eAntigravity 設計了三個相互配合的工作介面：\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"Editor + Agent Manager 雙介面\" loading=\"lazy\" src=\"/images/antigravity/official-blog/editor-agent-manager.png\"\u003e\n\u003cem\u003eEditor 與 Agent Manager 的雙介面架構\u003c/em\u003e\u003c/p\u003e\n\u003ch4 id=\"1-agent-manager非同步任務中心\"\u003e1. \u003cstrong\u003eAgent Manager\u003c/strong\u003e（非同步任務中心）\u003c/h4\u003e\n\u003cp\u003e這是你的「任務控制中心」，可以：\u003c/p\u003e","title":"Google Antigravity：AI 開發的典範轉移，從輔助編碼到自主開發"},{"content":"前言 在現代 Web 開發中，Vue 單頁應用程式（SPA）帶來了優秀的使用者體驗，但同時也面臨著 SEO 和社交媒體分享的挑戰。當用戶在 Facebook、LINE 或其他社交平台分享你的網站連結時，你希望顯示的是精美的預覽卡片，而不是空白或錯誤的資訊。然而，搜尋引擎爬蟲和社交媒體爬蟲無法執行 JavaScript，導致只能抓到空白的 HTML 骨架，Open Graph 標籤也無法正確顯示。\n本文將深入探討兩種預渲染策略：Run-time Prerender（動態預渲染） 和 Build-time Prerender（靜態預渲染），並分享實際的實作經驗與部署過程中遇到的挑戰。\n問題發現與場景 典型的問題場景 當開發一個基於 Vue.js 3 + Strapi 的網頁平台時，可能會發現一個重要問題：\n當用戶分享服務頁面（如 https://www.abc.com/service-us/6）到 Facebook 或 LINE 時，顯示的預覽資訊總是預設值，而非該服務的實際標題和描述。\n使用 Facebook 的 Open Graph Debugger 測試後發現，爬蟲抓取到的 HTML 只包含基本的模板，動態生成的 meta 標籤完全沒有被識別。\nSPA 的 SEO 困境 典型的 Vue SPA 在未預渲染前，HTML 長這樣：\n\u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;zh-TW\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;title\u0026gt;範例網站\u0026lt;/title\u0026gt; \u0026lt;meta name=\u0026#34;description\u0026#34; content=\u0026#34;預設描述\u0026#34;\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;div id=\u0026#34;app\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;script type=\u0026#34;module\u0026#34; src=\u0026#34;/src/main.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 問題：\nFacebook 爬蟲抓不到動態產生的 Open Graph 標籤 Google 爬蟲雖然能執行 JS，但會降低 SEO 排名 分享連結到社群媒體時無法顯示預覽圖 我們需要什麼？ 針對不同的路由，回傳不同的 meta 標籤：\n\u0026lt;!-- /service-us/1 --\u0026gt; \u0026lt;title\u0026gt;線上課程 - 範例網站\u0026lt;/title\u0026gt; \u0026lt;meta property=\u0026#34;og:title\u0026#34; content=\u0026#34;線上課程 - 範例網站\u0026#34;\u0026gt; \u0026lt;meta property=\u0026#34;og:image\u0026#34; content=\u0026#34;https://example.com/course.webp\u0026#34;\u0026gt; \u0026lt;!-- /latest-news/category/5 --\u0026gt; \u0026lt;title\u0026gt;最新消息：健康飲食新知 - 範例網站\u0026lt;/title\u0026gt; \u0026lt;meta property=\u0026#34;og:title\u0026#34; content=\u0026#34;最新消息：健康飲食新知 - 範例網站\u0026#34;\u0026gt; \u0026lt;meta property=\u0026#34;og:image\u0026#34; content=\u0026#34;https://example.com/news.webp\u0026#34;\u0026gt; 根本原因分析 客戶端渲染 vs 伺服器端渲染 問題的核心在於 客戶端渲染（CSR） 與 社交媒體爬蟲 之間的根本衝突：\n\u0026lt;!-- 社交媒體爬蟲看到的 HTML --\u0026gt; \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;title\u0026gt;ABCDEFG\u0026lt;/title\u0026gt; \u0026lt;!-- 只有靜態的預設 meta 標籤 --\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;div id=\u0026#34;app\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;!-- 爬蟲不會執行這些 JavaScript --\u0026gt; \u0026lt;script src=\u0026#34;/assets/index.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 而 Vue.js 應用動態設定的 meta 標籤：\n// Vue 組件中的動態 meta 設定 const metaTitle = computed(() =\u0026gt; { const openGraph = response.value?.openGraph return openGraph?.ogTitle || response.value?.title || \u0026#34;預設標題\u0026#34; }) useHead(() =\u0026gt; ({ title: metaTitle.value, meta: [ { property: \u0026#39;og:title\u0026#39;, content: metaTitle.value }, { property: \u0026#39;og:description\u0026#39;, content: metaDescription.value }, // ... 其他 meta 標籤 ] })) Facebook、LINE 等社交媒體的爬蟲不執行 JavaScript，因此看不到動態生成的內容。\n兩種預渲染策略比較 Run-time Prerender（動態預渲染） 時機： 爬蟲訪問時即時產生\n爬蟲請求 → Express Server 檢測 User-Agent → 即時讀取 dist/index.html → 動態注入 meta tags → 快取 24 小時 → 回傳 HTML Build-time Prerender（靜態預渲染） 時機： npm build 時一次性產生所有頁面\nnpm run build → Vite 打包 → Puppeteer 爬取所有路由 → 產生靜態 HTML → 儲存到 dist/service-us/1/index.html 功能對比表 特性 Run-time Build-time 產生時機 爬蟲訪問時 建置時 檔案結構 只有 1 個 index.html 每個路由一個 index.html 伺服器需求 Node.js + Express 靜態檔案伺服器（Nginx） 快取策略 記憶體快取（24小時） 檔案系統快取（永久） 動態路由 支援（可即時處理新 ID） 不支援（需重新建置） 建置時間 快速（只打包一次） 慢（需爬取所有路由） 維護成本 中等（需監控伺服器） 低（靜態檔案） 適用場景 內容經常變動 內容相對固定 Run-time Prerender 完整實作 架構圖 ┌─────────────────────────────────────────────────┐ │ 爬蟲請求 │ │ User-Agent: facebookexternalhit │ └──────────────────┬──────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────┐ │ Express Middleware │ │ src/utils/crawlerDetection.js │ │ ↓ 檢測是否為爬蟲 │ └──────────────────┬──────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────┐ │ ServerPrerenderService │ │ server/ServerPrerenderService.js │ │ │ │ 1. 檢查快取 (Map) │ │ 2. 讀取 dist/index.html │ │ 3. 根據 URL 產生 meta tags │ │ 4. 注入 structured data │ │ 5. 儲存快取 │ └──────────────────┬──────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────┐ │ 回傳預渲染 HTML │ │ \u0026lt;html data-prerendered=\u0026#34;true\u0026#34;\u0026gt; │ │ \u0026lt;meta property=\u0026#34;og:title\u0026#34; content=\u0026#34;...\u0026#34;\u0026gt; │ │ \u0026lt;/html\u0026gt; │ └─────────────────────────────────────────────────┘ Step 1: 爬蟲偵測 // src/utils/crawlerDetection.js const BOT_USER_AGENTS = { facebook: /facebookexternalhit|facebookcatalog/i, twitter: /twitterbot/i, linkedin: /linkedinbot/i, google: /googlebot/i, bing: /bingbot/i, line: /line/i } export function getBotType(userAgent) { if (!userAgent) return null for (const [type, regex] of Object.entries(BOT_USER_AGENTS)) { if (regex.test(userAgent)) { return type } } return null } export function shouldPrerender(request) { const userAgent = request.headers?.[\u0026#39;user-agent\u0026#39;] || \u0026#39;\u0026#39; const url = request.url || \u0026#39;\u0026#39; // 排除靜態資源 if (url.match(/\\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$/)) { return false } // 排除 API 請求 if (url.startsWith(\u0026#39;/api/\u0026#39;) || url.startsWith(\u0026#39;/_\u0026#39;)) { return false } // 檢測是否為爬蟲 return getBotType(userAgent) !== null } Step 2: Express 伺服器 // server/prerenderServer.js import express from \u0026#39;express\u0026#39; import { ServerPrerenderService } from \u0026#39;./ServerPrerenderService.js\u0026#39; const app = express() const prerenderService = new ServerPrerenderService() // 預渲染中間件 app.use(async (req, res, next) =\u0026gt; { try { const userAgent = req.headers[\u0026#39;user-agent\u0026#39;] || \u0026#39;\u0026#39; console.log(`🔍 Request: ${req.method} ${req.url}`) console.log(`🤖 User-Agent: ${userAgent}`) const html = await prerenderService.handleRequest(req) if (html) { console.log(`✅ Serving prerendered content for ${req.url}`) res.setHeader(\u0026#39;Content-Type\u0026#39;, \u0026#39;text/html; charset=utf-8\u0026#39;) res.setHeader(\u0026#39;Cache-Control\u0026#39;, \u0026#39;public, max-age=3600\u0026#39;) // 1 小時 return res.send(html) } } catch (error) { console.error(\u0026#39;❌ Prerender error:\u0026#39;, error) } next() }) // 靜態檔案 app.use(express.static(\u0026#39;dist\u0026#39;)) // 健康檢查 app.get(\u0026#39;/health\u0026#39;, (req, res) =\u0026gt; { res.json({ status: \u0026#39;healthy\u0026#39;, service: \u0026#39;prerender-server\u0026#39; }) }) // 快取管理 app.get(\u0026#39;/_prerender/stats\u0026#39;, (req, res) =\u0026gt; { res.json(prerenderService.getCacheStats()) }) app.post(\u0026#39;/_prerender/clear\u0026#39;, (req, res) =\u0026gt; { prerenderService.clearCache() res.json({ success: true }) }) // SPA Fallback app.get(\u0026#39;*\u0026#39;, (req, res) =\u0026gt; { res.sendFile(join(__dirname, \u0026#39;../dist/index.html\u0026#39;)) }) const port = process.env.PORT || 3001 app.listen(port, () =\u0026gt; { console.log(`🚀 Prerender server running on port ${port}`) }) Step 3: 預渲染服務 // server/ServerPrerenderService.js import fs from \u0026#39;fs\u0026#39; import { join } from \u0026#39;path\u0026#39; import { shouldPrerender, getBotType } from \u0026#39;../src/utils/crawlerDetection.js\u0026#39; export class ServerPrerenderService { constructor() { this.cache = new Map() this.cacheTimeout = 24 * 60 * 60 * 1000 // 24 小時 this.maxCacheSize = 100 this.distPath = join(__dirname, \u0026#39;../dist\u0026#39;) this.indexPath = join(this.distPath, \u0026#39;index.html\u0026#39;) } // 產生快取鍵 generateCacheKey(url, userAgent = \u0026#39;\u0026#39;) { const botType = getBotType(userAgent) const cleanUrl = url.split(\u0026#39;?\u0026#39;)[0].split(\u0026#39;#\u0026#39;)[0] return `${cleanUrl}:${botType}` } // 取得快取 getCachedHTML(url, userAgent = \u0026#39;\u0026#39;) { const key = this.generateCacheKey(url, userAgent) const cached = this.cache.get(key) if (!cached) return null // 檢查是否過期 const isExpired = Date.now() - cached.timestamp \u0026gt; this.cacheTimeout if (isExpired) { this.cache.delete(key) return null } return cached.html } // 儲存快取（LRU 策略） setCachedHTML(url, html, userAgent = \u0026#39;\u0026#39;) { if (this.cache.size \u0026gt;= this.maxCacheSize) { const oldestKey = this.cache.keys().next().value this.cache.delete(oldestKey) } const key = this.generateCacheKey(url, userAgent) this.cache.set(key, { html, timestamp: Date.now(), url }) } // 動態產生 HTML async generateStaticHTML(url, userAgent = \u0026#39;\u0026#39;) { try { let html = fs.readFileSync(this.indexPath, \u0026#39;utf-8\u0026#39;) const botType = getBotType(userAgent) const cleanUrl = url.split(\u0026#39;?\u0026#39;)[0].split(\u0026#39;#\u0026#39;)[0] // 產生 meta tags const metaTags = await this.generateMetaTags(cleanUrl, botType) // 替換 title html = html.replace( /\u0026lt;title\u0026gt;.*?\u0026lt;\\/title\u0026gt;/i, `\u0026lt;title\u0026gt;${metaTags.title}\u0026lt;/title\u0026gt;` ) // 注入 Open Graph 和 Twitter 標籤 const metaTagsHTML = Object.entries(metaTags.meta).map(([key, value]) =\u0026gt; { if (key.startsWith(\u0026#39;og:\u0026#39;) || key.startsWith(\u0026#39;twitter:\u0026#39;)) { return ` \u0026lt;meta property=\u0026#34;${key}\u0026#34; content=\u0026#34;${value}\u0026#34;\u0026gt;` } else { return ` \u0026lt;meta name=\u0026#34;${key}\u0026#34; content=\u0026#34;${value}\u0026#34;\u0026gt;` } }).join(\u0026#39;\\n\u0026#39;) html = html.replace( /\u0026lt;head\u0026gt;/i, `\u0026lt;head\u0026gt;\\n${metaTagsHTML}` ) // 加入 structured data const structuredData = this.generateStructuredData(cleanUrl) if (structuredData) { html = html.replace( /\u0026lt;\\/head\u0026gt;/i, ` \u0026lt;script type=\u0026#34;application/ld+json\u0026#34;\u0026gt;${JSON.stringify(structuredData, null, 2)}\u0026lt;/script\u0026gt;\\n \u0026lt;/head\u0026gt;` ) } // 標記為預渲染 html = html.replace( /\u0026lt;html/i, `\u0026lt;html data-prerendered=\u0026#34;true\u0026#34; data-bot-type=\u0026#34;${botType}\u0026#34;` ) return html } catch (error) { console.error(\u0026#39;❌ Error generating HTML:\u0026#39;, error) return null } } // 根據路由產生 meta tags async generateMetaTags(url, botType = \u0026#39;generic\u0026#39;) { const baseUrl = process.env.VITE_BASE_URL || \u0026#39;https://www.example.com\u0026#39; const fullUrl = `${baseUrl}${url}` const defaultMeta = { title: \u0026#39;範例網站-人類肥胖的終極解答\u0026#39;, description: \u0026#39;數位內容平台，分享專業知識與實用資訊\u0026#39;, image: `${baseUrl}/images/og-default.jpg` } // 靜態路由對應表 const routeMetaMap = { \u0026#39;/about-us\u0026#39;: { title: \u0026#39;關於我們 - 範例網站\u0026#39;, description: \u0026#39;了解範例網站的理念、服務與專業團隊\u0026#39;, image: \u0026#39;https://wuling-strapi-prod-s3.s3.ap-southeast-1.amazonaws.com/4_2_R_3.jpg\u0026#39; }, \u0026#39;/online-class\u0026#39;: { title: \u0026#39;線上課程 - 範例網站\u0026#39;, description: \u0026#39;豐富的健康管理線上課程，由專業醫師授課\u0026#39;, image: \u0026#39;https://wuling-strapi-prod-s3.s3.ap-southeast-1.amazonaws.com/01.jpg\u0026#39; } } let meta = defaultMeta // 處理靜態路由 if (routeMetaMap[url]) { meta = { ...defaultMeta, ...routeMetaMap[url] } } // 處理動態路由（使用 fallback 資料） else if (url.includes(\u0026#39;/service-us/\u0026#39;)) { const serviceId = url.split(\u0026#39;/service-us/\u0026#39;)[1]?.split(\u0026#39;?\u0026#39;)[0] if (serviceId) { const serviceMeta = this.getServiceFallback(serviceId) meta = { ...defaultMeta, ...serviceMeta } } } else if (url.includes(\u0026#39;/latest-news/category/\u0026#39;)) { const newsId = url.split(\u0026#39;/latest-news/category/\u0026#39;)[1]?.split(\u0026#39;?\u0026#39;)[0] if (newsId) { const newsMeta = this.getNewsFallback(newsId) meta = { ...defaultMeta, ...newsMeta } } } return { title: meta.title, meta: { \u0026#39;description\u0026#39;: meta.description, \u0026#39;robots\u0026#39;: \u0026#39;index,follow\u0026#39;, \u0026#39;og:title\u0026#39;: meta.title, \u0026#39;og:description\u0026#39;: meta.description, \u0026#39;og:image\u0026#39;: meta.image, \u0026#39;og:url\u0026#39;: fullUrl, \u0026#39;og:type\u0026#39;: \u0026#39;website\u0026#39;, \u0026#39;og:site_name\u0026#39;: \u0026#39;範例網站\u0026#39;, \u0026#39;twitter:card\u0026#39;: \u0026#39;summary_large_image\u0026#39;, \u0026#39;twitter:title\u0026#39;: meta.title, \u0026#39;twitter:description\u0026#39;: meta.description, \u0026#39;twitter:image\u0026#39;: meta.image } } } // Fallback 資料（避免每次都查詢 API） getServiceFallback(serviceId) { const fallbacks = { \u0026#39;1\u0026#39;: { title: \u0026#39;健康管理服務 - 範例網站\u0026#39;, description: \u0026#39;專業的健康管理和醫療諮詢服務\u0026#39;, image: \u0026#39;https://example.com/service-1.jpg\u0026#39; } // ...更多 fallback 資料 } return fallbacks[serviceId] || {} } // 產生 structured data generateStructuredData(url) { const baseUrl = process.env.VITE_BASE_URL || \u0026#39;https://www.example.com\u0026#39; if (url === \u0026#39;/\u0026#39;) { return { \u0026#39;@context\u0026#39;: \u0026#39;https://schema.org\u0026#39;, \u0026#39;@type\u0026#39;: \u0026#39;WebSite\u0026#39;, \u0026#39;name\u0026#39;: \u0026#39;範例網站\u0026#39;, \u0026#39;url\u0026#39;: baseUrl, \u0026#39;description\u0026#39;: \u0026#39;數位內容平台，分享專業知識與實用資訊\u0026#39; } } if (url.includes(\u0026#39;/service-us/\u0026#39;)) { return { \u0026#39;@context\u0026#39;: \u0026#39;https://schema.org\u0026#39;, \u0026#39;@type\u0026#39;: \u0026#39;MedicalBusiness\u0026#39;, \u0026#39;name\u0026#39;: \u0026#39;範例健康服務\u0026#39;, \u0026#39;url\u0026#39;: `${baseUrl}${url}` } } return null } // 處理請求 async handleRequest(req) { const userAgent = req.headers[\u0026#39;user-agent\u0026#39;] || \u0026#39;\u0026#39; const url = req.url if (!shouldPrerender({ headers: req.headers, url })) { return null } try { // 先檢查快取 const cachedHTML = this.getCachedHTML(url, userAgent) if (cachedHTML) { console.log(`💾 Serving from cache: ${url}`) return cachedHTML } console.log(`🔨 Generating HTML for: ${url} (${getBotType(userAgent)})`) // 產生新的 HTML const html = await this.generateStaticHTML(url, userAgent) if (html) { this.setCachedHTML(url, html, userAgent) return html } return null } catch (error) { console.error(\u0026#39;❌ Prerender error:\u0026#39;, error) return null } } // 快取統計 getCacheStats() { return { size: this.cache.size, maxSize: this.maxCacheSize, keys: Array.from(this.cache.keys()), memory: process.memoryUsage() } } // 清除快取 clearCache() { this.cache.clear() console.log(\u0026#39;🗑️ Cache cleared\u0026#39;) } } Step 4: package.json 設定 { \u0026#34;scripts\u0026#34;: { \u0026#34;dev\u0026#34;: \u0026#34;vite --host --mode dev\u0026#34;, \u0026#34;build\u0026#34;: \u0026#34;vite build\u0026#34;, \u0026#34;prerender:server\u0026#34;: \u0026#34;node server/prerenderServer.js\u0026#34;, \u0026#34;build:prerender\u0026#34;: \u0026#34;npm run build \u0026amp;\u0026amp; npm run prerender:server\u0026#34; } } Meta Server 整合與 GraphQL 支援 與 Strapi GraphQL 整合 如果你的後端使用 Strapi 並透過 GraphQL 提供資料，可以在 Meta Server 中整合 Apollo Client：\n// meta-server.js 核心邏輯 const express = require(\u0026#39;express\u0026#39;); const { ApolloClient, InMemoryCache } = require(\u0026#39;@apollo/client/core\u0026#39;); const app = express(); // Apollo Client 設定，連接到 Strapi GraphQL API const client = new ApolloClient({ uri: process.env.VITE_GRAPHQL_API_URL + \u0026#39;/graphql\u0026#39;, cache: new InMemoryCache(), headers: { authorization: `Bearer ${process.env.VITE_GRAPHQL_ACCESS_TOKEN}` } }); // GraphQL 查詢定義 const GET_SERVICE_PAGE = gql` query GetServicePage($pagination: PaginationArg) { layoutDSingles(pagination: $pagination) { id title content openGraph { ogTitle ogDescription ogImage } } } `; // User Agent 檢測函數 function isCrawlerRequest(userAgent) { const crawlers = [ \u0026#39;facebookexternalhit\u0026#39;, \u0026#39;Twitterbot\u0026#39;, \u0026#39;LinkedInBot\u0026#39;, \u0026#39;WhatsApp\u0026#39;, \u0026#39;Line\u0026#39;, \u0026#39;LineBotMessenger\u0026#39;, \u0026#39;Googlebot\u0026#39; ]; return crawlers.some(crawler =\u0026gt; userAgent.toLowerCase().includes(crawler.toLowerCase()) ); } // 處理服務頁面的動態 meta 標籤 app.get(\u0026#39;/service-us/:id\u0026#39;, async (req, res) =\u0026gt; { const serviceId = req.params.id; const userAgent = req.get(\u0026#39;User-Agent\u0026#39;) || \u0026#39;\u0026#39;; console.log(`Request for service ${serviceId} from: ${userAgent}`); try { // 從 Strapi 獲取服務資料 const { data } = await client.query({ query: GET_SERVICE_PAGE, variables: { pagination: { limit: 100 } } }); const serviceData = data?.layoutDSingles?.find( item =\u0026gt; String(item.id) === String(serviceId) ); if (!serviceData) { return res.status(404).send(\u0026#39;Service not found\u0026#39;); } // 生成動態 meta 標籤 const metaTags = generateMetaTags(serviceData, req.originalUrl); // 將 meta 標籤注入 HTML 模板 const html = htmlTemplate.replace( \u0026#39;\u0026lt;!-- SEO_META_PLACEHOLDER --\u0026gt;\u0026#39;, metaTags ); res.setHeader(\u0026#39;Content-Type\u0026#39;, \u0026#39;text/html\u0026#39;); res.send(html); } catch (error) { console.error(\u0026#39;Error fetching service data:\u0026#39;, error); // 回退到預設 meta 標籤 const defaultMetaTags = generateMetaTags({}, req.originalUrl); const html = htmlTemplate.replace( \u0026#39;\u0026lt;!-- SEO_META_PLACEHOLDER --\u0026gt;\u0026#39;, defaultMetaTags ); res.send(html); } }); Build-time Prerender 實作概覽 檔案結構差異 # Run-time (1 個檔案) dist/ └── index.html # Build-time (每個路由一個檔案) dist/ ├── index.html ├── about-us/ │ └── index.html ├── service-us/ │ ├── 1/ │ │ └── index.html │ └── 2/ │ └── index.html └── latest-news/ └── category/ ├── 1/ │ └── index.html └── 2/ └── index.html Puppeteer 爬取腳本 // scripts/prerender.js import puppeteer from \u0026#39;puppeteer\u0026#39; import fs from \u0026#39;fs\u0026#39; import path from \u0026#39;path\u0026#39; const routes = [ \u0026#39;/\u0026#39;, \u0026#39;/about-us\u0026#39;, \u0026#39;/online-class\u0026#39;, \u0026#39;/service-us/1\u0026#39;, \u0026#39;/service-us/2\u0026#39;, \u0026#39;/latest-news/category/1\u0026#39;, \u0026#39;/latest-news/category/2\u0026#39; ] async function prerenderRoutes() { const browser = await puppeteer.launch() for (const route of routes) { console.log(`🔨 Prerendering ${route}...`) const page = await browser.newPage() await page.goto(`http://localhost:4173${route}`, { waitUntil: \u0026#39;networkidle0\u0026#39; }) // 等待 Vue 渲染完成 await page.waitForSelector(\u0026#39;[data-prerendered]\u0026#39;) const html = await page.content() // 儲存 HTML const filePath = route === \u0026#39;/\u0026#39; ? \u0026#39;dist/index.html\u0026#39; : `dist${route}/index.html` const dir = path.dirname(filePath) if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }) } fs.writeFileSync(filePath, html) console.log(`✅ Saved ${filePath}`) } await browser.close() } prerenderRoutes() package.json 腳本 { \u0026#34;scripts\u0026#34;: { \u0026#34;build\u0026#34;: \u0026#34;vite build\u0026#34;, \u0026#34;preview\u0026#34;: \u0026#34;vite preview\u0026#34;, \u0026#34;prerender\u0026#34;: \u0026#34;node scripts/prerender.js\u0026#34;, \u0026#34;build:prerender\u0026#34;: \u0026#34;npm run build \u0026amp;\u0026amp; npm run preview \u0026amp; sleep 5 \u0026amp;\u0026amp; npm run prerender \u0026amp;\u0026amp; pkill -f \u0026#39;vite preview\u0026#39;\u0026#34; } } 技術細節解析 1. User-Agent 偵測原理 const BOT_USER_AGENTS = { facebook: /facebookexternalhit|facebookcatalog/i, twitter: /twitterbot/i, google: /googlebot/i } // Facebook 爬蟲的 User-Agent 範例： // \u0026#34;facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)\u0026#34; 2. 快取策略：LRU (Least Recently Used) setCachedHTML(url, html, userAgent = \u0026#39;\u0026#39;) { // 超過上限時刪除最舊的項目 if (this.cache.size \u0026gt;= this.maxCacheSize) { const oldestKey = this.cache.keys().next().value this.cache.delete(oldestKey) } this.cache.set(key, { html, timestamp: Date.now(), url }) } 使用 JavaScript Map 的特性：\nMap 保持插入順序 keys().next().value 取得第一個（最舊）的鍵 3. Cache-Control 標頭設定 res.setHeader(\u0026#39;Cache-Control\u0026#39;, \u0026#39;public, max-age=3600\u0026#39;) public：允許 CDN 快取 max-age=3600：快取 1 小時（3600 秒） 可以根據需求調整：\n靜態頁面：max-age=86400（1 天） 動態內容：max-age=300（5 分鐘） 4. HTML 注入技巧 // 在 \u0026lt;head\u0026gt; 開頭注入 meta tags html = html.replace( /\u0026lt;head\u0026gt;/i, `\u0026lt;head\u0026gt;\\n${metaTagsHTML}` ) // 在 \u0026lt;/head\u0026gt; 前注入 structured data html = html.replace( /\u0026lt;\\/head\u0026gt;/i, ` \u0026lt;script type=\u0026#34;application/ld+json\u0026#34;\u0026gt;${JSON.stringify(structuredData)}\u0026lt;/script\u0026gt;\\n \u0026lt;/head\u0026gt;` ) 使用正規表達式的 i flag 進行大小寫不敏感比對。\n5. Fallback 資料設計 為什麼需要 Fallback？\n// ❌ 每次都查詢 API（慢） const serviceMeta = await fetch(`${API_URL}/services/${serviceId}`) // ✅ 使用靜態 fallback（快） const serviceMeta = getServiceFallback(serviceId) Fallback 資料可以：\n手動維護在程式碼中 從 JSON 檔案讀取 定期從 API 更新並快取 驗證與測試 1. 本機測試 # 啟動預渲染伺服器 npm run build npm run prerender:server # 模擬 Facebook 爬蟲 curl -A \u0026#34;facebookexternalhit/1.1\u0026#34; http://localhost:3001/service-us/1 # 查看快取統計 curl http://localhost:3001/_prerender/stats # 清除快取 curl -X POST http://localhost:3001/_prerender/clear 2. 檢查預渲染是否生效 # 查看 HTML 是否包含正確的 meta tags curl -A \u0026#34;facebookexternalhit/1.1\u0026#34; http://localhost:3001/about-us | grep \u0026#34;og:title\u0026#34; # 應該會看到： # \u0026lt;meta property=\u0026#34;og:title\u0026#34; content=\u0026#34;關於我們 - 範例網站\u0026#34;\u0026gt; 3. Facebook 除錯工具 使用 Facebook Sharing Debugger：\n輸入你的網址 點擊「除錯」 查看「抓取資訊」是否正確顯示 Open Graph 標籤 4. LINE 分享測試 直接在 LINE 中分享連結，檢查預覽卡片是否顯示正確的標題和描述。\n5. 壓力測試 # 使用 Apache Bench 測試 ab -n 1000 -c 10 -H \u0026#34;User-Agent: facebookexternalhit/1.1\u0026#34; http://localhost:3001/service-us/1 # 結果應顯示： # - 快取命中率 \u0026gt; 99% # - 平均回應時間 \u0026lt; 50ms 6. 監控快取效能 app.get(\u0026#39;/_prerender/stats\u0026#39;, (req, res) =\u0026gt; { const stats = prerenderService.getCacheStats() res.json({ cacheSize: stats.size, maxSize: stats.maxSize, hitRate: calculateHitRate(), // 自行實作 memory: stats.memory, keys: stats.keys }) }) 部署注意事項 Run-time Prerender 部署 Docker Compose # docker-compose.yml services: prerender-server: build: context: . dockerfile: Dockerfile.prerender ports: - \u0026#34;3001:3001\u0026#34; environment: - NODE_ENV=production - VITE_BASE_URL=https://www.example.com restart: unless-stopped Dockerfile # Dockerfile.meta FROM node:18-alpine AS build WORKDIR /app # 安裝依賴並構建應用 COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # 生產環境 FROM node:18-alpine AS production WORKDIR /app COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/scripts/meta-server.js ./scripts/ COPY --from=build /app/package.json ./ EXPOSE 3001 CMD [\u0026#34;npm\u0026#34;, \u0026#34;run\u0026#34;, \u0026#34;meta-server\u0026#34;] Nginx 反向代理 # nginx.conf upstream prerender { server localhost:3001; } server { listen 80; server_name www.example.com; location / { proxy_pass http://prerender; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header User-Agent $http_user_agent; } } Kubernetes 部署配置 # my_web_meta_prod_development.yaml apiVersion: apps/v1 kind: Deployment metadata: name: my-web-meta spec: replicas: 1 selector: matchLabels: app: my-web-meta template: spec: containers: - name: my-web-meta-prod image: your-registry/my-web-meta:latest ports: - containerPort: 3001 env: - name: VITE_GRAPHQL_API_URL value: \u0026#34;https://api.abc.com\u0026#34; - name: VITE_GRAPHQL_ACCESS_TOKEN value: \u0026#34;your-token-here\u0026#34; Build-time Prerender 部署 Nginx 設定 server { listen 80; server_name www.example.com; root /var/www/dist; # 嘗試尋找預渲染的檔案 location / { try_files $uri $uri/index.html /index.html; } # 靜態資源快取 location ~* \\.(js|css|png|jpg|jpeg|gif|svg|ico)$ { expires 1y; add_header Cache-Control \u0026#34;public, immutable\u0026#34;; } } 效能比較 Run-time Prerender 優點：\n建置時間快（\u0026lt; 1 分鐘） 支援動態路由（新增內容無需重新部署） 彈性高（可即時調整 meta tags） 缺點：\n需要 Node.js 伺服器（成本較高） 首次訪問較慢（需即時產生 HTML） 需要監控伺服器狀態 Build-time Prerender 優點：\n伺服器成本低（只需靜態檔案伺服器） 回應速度快（直接讀取檔案） 穩定性高（無程式執行風險） 缺點：\n建置時間長（需爬取所有路由） 不支援動態路由（新增內容需重新建置） 檔案數量多（管理複雜） 實測數據（1000 次請求） 指標 Run-time (快取命中) Run-time (快取未命中) Build-time 平均回應時間 12ms 85ms 8ms 記憶體使用 150MB 150MB 50MB CPU 使用率 5% 30% 2% 建置時間 45秒 45秒 8分鐘 實施過程中的挑戰 1. GraphQL 認證問題 初期測試時，meta-server 無法連接到 Strapi GraphQL API：\n# 錯誤日誌 ConnectTimeoutError: Connect Timeout Error 解決方法：確保環境變數中包含正確的 GraphQL access token。\n2. Docker 構建問題 最初使用 npm ci --only=production 導致 Vite 無法找到：\nsh: vite: not found 解決方法：改用 npm ci 安裝所有依賴，包括 devDependencies。\n3. Kubernetes 資源限制 在部署過程中遇到節點資源不足的問題：\n0/1 nodes are available: 1 Too many pods 解決方法：採用並行部署策略，先測試新服務再切換流量。\n4. SSL 憑證配置錯誤 在切換服務過程中，意外發現 Ingress 中的 SSL 憑證名稱配置錯誤：\n# 錯誤的配置 secretName: www-abc-com-custom-cert # 不存在 # 正確的配置 secretName: www-abc-com-cert # 實際存在的憑證 常見問題 Q1: Run-time Prerender 會消耗很多記憶體嗎？ 取決於快取大小設定：\nthis.maxCacheSize = 100 // 約 50-100MB this.maxCacheSize = 1000 // 約 500MB - 1GB 建議根據伺服器規格調整：\n小型專案（\u0026lt; 100 頁）：100 個快取項目 中型專案（\u0026lt; 1000 頁）：500 個快取項目 大型專案：考慮使用 Redis Q2: 如何處理需要即時資料的頁面？ 方案 1：混合策略\n// 預渲染提供基本 meta tags（for 爬蟲） \u0026lt;title\u0026gt;線上課程 - 範例網站\u0026lt;/title\u0026gt; // Vue App 載入後更新為即時資料（for 使用者） onMounted(async () =\u0026gt; { const data = await fetchCourseData() document.title = `${data.title} - 範例網站` }) 方案 2：快取時間縮短\nthis.cacheTimeout = 5 * 60 * 1000 // 5 分鐘 Q3: 如何避免爬蟲快取過期資料？ 方案 1：主動清除快取\n// 當內容更新時，呼叫 API 清除快取 await fetch(\u0026#39;http://localhost:3001/_prerender/clear\u0026#39;, { method: \u0026#39;POST\u0026#39; }) 方案 2：版本化 URL\n// 在 URL 加入版本號 query.filters.user = { updatedAt: { $gte: \u0026#39;2024-01-01\u0026#39; } } Q4: Build-time 如何處理數千個動態路由？ 方案 1：部分預渲染\n只預渲染熱門頁面：\n// scripts/prerender.js const popularRoutes = await fetch(\u0026#39;/api/popular-pages?limit=100\u0026#39;) const routes = popularRoutes.map(r =\u0026gt; r.url) 方案 2：增量預渲染\n只重新建置更新的頁面：\nconst changedRoutes = await getChangedRoutes() // 從 Git 或資料庫取得 for (const route of changedRoutes) { await prerenderRoute(route) } Q5: 兩種策略可以混合使用嗎？ 可以！最佳實踐：\n┌─────────────────────────────────────┐ │ 靜態頁面（about-us, contact） │ │ → Build-time Prerender │ └─────────────────────────────────────┘ ┌─────────────────────────────────────┐ │ 動態頁面（service-us/*, news/*） │ │ → Run-time Prerender │ └─────────────────────────────────────┘ 實作方式：\n// Nginx 配置 location ~* ^/(about-us|contact) { # 直接讀取預渲染檔案 try_files $uri $uri/index.html /index.html; } location / { # 轉發到 Node.js 伺服器 proxy_pass http://prerender-server:3001; } 替代方案探討 雖然本文主要介紹了 Run-time 和 Build-time 兩種預渲染方案，但還有其他可行的解決方案：\n1. Cloudflare Workers 可以在 CDN 層面處理爬蟲請求：\naddEventListener(\u0026#39;fetch\u0026#39;, event =\u0026gt; { event.respondWith(handleRequest(event.request)) }) async function handleRequest(request) { const userAgent = request.headers.get(\u0026#39;User-Agent\u0026#39;) || \u0026#39;\u0026#39; if (isCrawler(userAgent)) { // 返回預渲染的 HTML return generateMetaHTML(request.url) } // 返回正常的 SPA return fetch(request) } 2. 預渲染服務 使用 Puppeteer 等工具預渲染特定頁面：\nconst puppeteer = require(\u0026#39;puppeteer\u0026#39;); async function prerenderPage(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: \u0026#39;networkidle0\u0026#39; }); const content = await page.content(); await browser.close(); return content; } 3. 遷移到 Nuxt.js 或其他 SSR 框架 優點：完整的 SSR 支援 缺點：需要重寫整個應用，成本過高 經驗教訓與最佳實踐 1. 理解社交媒體爬蟲的限制 不執行 JavaScript：所有動態內容對爬蟲都是不可見的 有特定的 User Agent：可以利用這點來識別爬蟲請求 快取機制：Facebook 等平台會快取 OpenGraph 資料 2. 部署策略 並行部署：同時運行新舊服務，降低停機風險 漸進式切換：先測試新服務，確認無誤後再切換流量 快速回滾：準備回滾腳本，遇到問題立即恢復 3. 監控與除錯 詳細日誌：記錄所有爬蟲請求和處理過程 健康檢查：確保服務正常運行 錯誤處理：優雅降級，避免服務中斷 4. 環境配置管理 敏感資訊保護：使用 Kubernetes Secrets 管理 API tokens 環境變數驗證：確保所有必要的配置都正確設定 憑證管理：定期檢查和更新 SSL 憑證 5. 技術選型的重要性 在專案初期就應該考慮 SEO 和社交媒體分享的需求，選擇合適的技術架構。\n6. 漸進式改進 不需要重寫整個應用，可以通過添加輔助服務來解決特定問題。\n總結 選擇指南 選擇 Run-time Prerender，如果：\n內容經常變動（每天更新） 有大量動態路由（\u0026gt; 100 個） 需要根據爬蟲類型客製化內容 有足夠的伺服器資源 選擇 Build-time Prerender，如果：\n內容相對固定（每週更新） 路由數量少（\u0026lt; 50 個） 追求極致的回應速度 想降低伺服器成本 關鍵要點 Run-time Prerender 本質是動態伺服器端渲染（SSR）的精簡版\n只針對爬蟲，不針對一般使用者 使用快取避免重複渲染 爬蟲偵測是核心\n必須準確判斷 User-Agent 避免一般使用者也拿到預渲染版本（影響互動功能） 快取策略要合理\n平衡記憶體使用和效能 設定合理的過期時間 提供手動清除機制 測試要充分\n使用 Facebook Debugger 驗證 模擬各種爬蟲的 User-Agent 壓力測試確保穩定性 混合策略是最佳解\n靜態頁面用 Build-time 動態頁面用 Run-time 根據實際需求靈活調整 部署安全\n採用並行部署和快速回滾策略 確保服務穩定性 充分的監控和日誌 最終建議 這次解決 OpenGraph 標籤問題的經歷讓我深刻理解了 SPA 應用在社交媒體分享方面的限制，以及如何通過技術手段克服這些挑戰。雖然實施過程中會遇到各種問題（GraphQL 認證、Docker 構建、Kubernetes 資源限制、SSL 憑證配置等），但只要理解問題的本質，選擇合適的方法，並在實施過程中保持謹慎和耐心，技術問題總有解決方案。\n記住，技術選型沒有絕對的對錯，關鍵是要根據你的專案特性、團隊能力和資源限制來選擇最合適的方案。\n完整的程式碼範例可以參考 GitHub Repository 或根據本文的範例進行調整。\n希望這篇文章能幫助你選擇適合的預渲染策略，並成功實現 Vue SPA 的 OpenGraph 支援！如果有任何問題，歡迎留言討論。\n標籤： #Vue.js #SEO #Prerender #SSR #Node.js #Express #Open-Graph #Performance #Strapi #GraphQL #Docker #Kubernetes\n相關文章：\nVue SPA SEO 完整指南 深入理解 Open Graph 協議 Express 中間件開發實戰 Web 效能優化：快取策略大全 Kubernetes 部署最佳實踐 ","permalink":"https://peter-notes.com/posts/vue-spa-prerender-guide/","summary":"\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e在現代 Web 開發中，Vue 單頁應用程式（SPA）帶來了優秀的使用者體驗，但同時也面臨著 SEO 和社交媒體分享的挑戰。當用戶在 Facebook、LINE 或其他社交平台分享你的網站連結時，你希望顯示的是精美的預覽卡片，而不是空白或錯誤的資訊。然而，搜尋引擎爬蟲和社交媒體爬蟲無法執行 JavaScript，導致只能抓到空白的 HTML 骨架，Open Graph 標籤也無法正確顯示。\u003c/p\u003e\n\u003cp\u003e本文將深入探討兩種預渲染策略：\u003cstrong\u003eRun-time Prerender（動態預渲染）\u003c/strong\u003e 和 \u003cstrong\u003eBuild-time Prerender（靜態預渲染）\u003c/strong\u003e，並分享實際的實作經驗與部署過程中遇到的挑戰。\u003c/p\u003e\n\u003ch2 id=\"問題發現與場景\"\u003e問題發現與場景\u003c/h2\u003e\n\u003ch3 id=\"典型的問題場景\"\u003e典型的問題場景\u003c/h3\u003e\n\u003cp\u003e當開發一個基於 Vue.js 3 + Strapi 的網頁平台時，可能會發現一個重要問題：\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e當用戶分享服務頁面（如 \u003ccode\u003ehttps://www.abc.com/service-us/6\u003c/code\u003e）到 Facebook 或 LINE 時，顯示的預覽資訊總是預設值，而非該服務的實際標題和描述。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e使用 Facebook 的 \u003ca href=\"https://developers.facebook.com/tools/debug/\"\u003eOpen Graph Debugger\u003c/a\u003e 測試後發現，爬蟲抓取到的 HTML 只包含基本的模板，動態生成的 meta 標籤完全沒有被識別。\u003c/p\u003e\n\u003ch3 id=\"spa-的-seo-困境\"\u003eSPA 的 SEO 困境\u003c/h3\u003e\n\u003cp\u003e典型的 Vue SPA 在未預渲染前，HTML 長這樣：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e\u0026lt;!DOCTYPE html\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ehtml\u003c/span\u003e \u003cspan class=\"na\"\u003elang\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;zh-TW\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ehead\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003emeta\u003c/span\u003e \u003cspan class=\"na\"\u003echarset\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;UTF-8\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003etitle\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e範例網站\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003etitle\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003emeta\u003c/span\u003e \u003cspan class=\"na\"\u003ename\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;description\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003econtent\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;預設描述\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ehead\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ebody\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ediv\u003c/span\u003e \u003cspan class=\"na\"\u003eid\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;app\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ediv\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003escript\u003c/span\u003e \u003cspan class=\"na\"\u003etype\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;module\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003esrc\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;/src/main.js\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003escript\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ebody\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ehtml\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e問題：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eFacebook 爬蟲抓不到動態產生的 Open Graph 標籤\u003c/li\u003e\n\u003cli\u003eGoogle 爬蟲雖然能執行 JS，但會降低 SEO 排名\u003c/li\u003e\n\u003cli\u003e分享連結到社群媒體時無法顯示預覽圖\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"我們需要什麼\"\u003e我們需要什麼？\u003c/h3\u003e\n\u003cp\u003e針對不同的路由，回傳不同的 meta 標籤：\u003c/p\u003e","title":"Vue SPA 預渲染與 OpenGraph 完整指南：從問題到解決方案"},{"content":" 「老闆，用戶的解鎖記錄全不見了！」「快把舊資料拉出來灌回去！」在緊急狀況下，我沒想太多就照做了。然後，我不小心埋下了一顆定時炸彈\u0026hellip;\n🔥 第一幕：災難降臨 2025 年 11 月某日，上午 10:30\nSlack 突然炸開：\n💬 同事：「完蛋了\u0026hellip;我剛剛不小心部署到舊的 commit\u0026hellip;」\n💬 QA：「欸！為什麼使用者的動畫解鎖記錄都不見了？」\n💬 使用者：「我昨天才花金幣解鎖的動畫怎麼不見了？」\n💬 老闆：「@所有人 立刻確認影響範圍！」\n我打開資料庫一看：\nSELECT COUNT(*) FROM user_unlocked_animations; -- 結果: 0 😱 所有用戶的解鎖記錄全部消失！ 原因：同事不小心部署了一個舊的 Strapi commit，那個版本的 database migration 把 user_unlocked_animations 相關的表全部清空了。\n⚡ 第二幕：老闆的緊急命令 💬 老闆：「快！把之前的用戶解鎖記錄拉出來，灌回現在的資料庫！」\n我心裡想：「舊資料插回去，新資料又同時在進來\u0026hellip;會不會有問題？」\n但老闆在等，使用者在抱怨，沒時間多想，先恢復資料再說。\n緊急恢復資料 // 從備份拉出資料，直接插入（包含原始的 ID） blablablabla } // ⚠️ 直接指定了 id，但沒想到要更新 sequence... 執行完畢：\n✅ 資料恢復完成！ QA 測試：「使用者的解鎖記錄都回來了！」\n眾人鬆了一口氣。\n💣 第三幕：24 小時後，炸彈引爆 隔天下午\n💬 客服：「有使用者回報說無法解鎖動畫!!!」\n💬 使用者：「我有 3 個金幣，想解鎖動畫，但一直顯示錯誤！金幣被扣了但動畫沒解鎖！」\n打開 CloudWatch（應用層 log）：\n[2025-11-06T15:32:47] ERROR: Failed to unlock animation Error: Unique constraint failed 檢查程式碼邏輯 → 沒問題 檢查使用者請求 → 沒有重複 檢查資料庫 → 沒有重複資料\n整個下午都找不到原因。\n🔦 第四幕：深夜的突破 晚上 9:00\n我決定換個角度：「去看資料庫層的 log！」\n切換到 PostgreSQL 的 CloudWatch Log Group：\nLog Group: /aws/rds/instance/my-db/postgresql 搜尋: \u0026#34;duplicate key\u0026#34; \u0026#34;user_unlocked_animations\u0026#34; 螢幕上顯示：\nERROR: duplicate key value violates unique constraint \u0026#34;user_unlocked_animations_user_lnk_pkey\u0026#34; DETAIL: Key (id)=(8) already exists. 我立刻檢查：\n-- 資料表有 10 筆資料 SELECT COUNT(*) FROM user_unlocked_animations_user_lnk; -- 結果: 10 SELECT MAX(id) FROM user_unlocked_animations_user_lnk; -- 結果: 15 -- Sequence 只記到 7 SELECT last_value FROM user_unlocked_animations_user_lnk_id_seq; -- 結果: 7 -- 😱 有 10 筆資料，MAX(id)=15，但 sequence 只有 7！ 我終於明白了。\n🧠 第五幕：真相大白 問題核心：Sequence 跟實際資料筆數不同步 什麼是 Sequence？\nPostgreSQL 的每個自動遞增 id 都有一個 sequence（序列生成器）：\nCREATE TABLE user_unlocked_animations_user_lnk ( id SERIAL PRIMARY KEY -- 自動建立 sequence ); -- 對應的 sequence: user_unlocked_animations_user_lnk_id_seq 正常情況：\nINSERT INTO table (user_id, data) VALUES (100, \u0026#39;foo\u0026#39;); -- PostgreSQL: 從 sequence 取得 id=1，sequence 變成 2 ✅ INSERT INTO table (user_id, data) VALUES (101, \u0026#39;bar\u0026#39;); -- PostgreSQL: 從 sequence 取得 id=2，sequence 變成 3 ✅ 用簡單例子理解問題 假設資料表現狀：\nid user_id animation_id 來源 1-8 \u0026hellip; \u0026hellip; 昨天恢復的舊資料 9-10 \u0026hellip; \u0026hellip; 昨天到今天新解鎖 資料表狀態：\n有 10 筆資料 MAX(id) = 15（中間有些 id 跳號） Sequence 狀態：\nlast_value = 7 為什麼 sequence 只有 7？\n因為我昨天直接指定 ID 插入（id=1 到 id=8），PostgreSQL 的 sequence 完全沒被呼叫，所以還停留在很小的值。\n但實際情況更複雜！\n當時上線期間有約 500 人同時註冊，也就是說：\n🔄 舊資料正在灌入（手動插入，指定 id=1~8） 🔄 新資料同時在寫入（系統自動插入，使用 sequence 生成 id） 🔄 兩個寫入流程在 race！ 這導致了更混亂的狀況：\n時間軸： 10:35 - 開始恢復舊資料（id=1~8，sequence 未更新） 10:36 - 同時有新用戶解鎖（sequence 自動遞增：7→8→9→10） 10:37 - 舊資料繼續插入（id=5, 6, 7, 8...） 10:38 - 新用戶又解鎖（sequence：10→11→12...） 所以資料表裡的 id 會像這樣：\nid 來源 插入方式 1-8 舊資料恢復 手動指定 ID 9-12 恢復期間新解鎖 sequence 生成 13-15 恢復後新解鎖 sequence 生成 有 10 筆資料，MAX(id)=15，但 sequence 可能停在任何值（取決於恢復時有多少新寫入）。\n今天新使用者解鎖時：\n-- 系統嘗試插入 INSERT INTO user_unlocked_animations_user_lnk (user_id, animation_id) VALUES (125, 7); -- PostgreSQL: -- 1. 從 sequence 取得 id SELECT nextval(\u0026#39;user_unlocked_animations_user_lnk_id_seq\u0026#39;); -- 結果: 8 (因為 last_value=7，下一個是 8) -- 2. 嘗試插入 id=8 -- 3. 💥 ERROR! id=8 已經存在（是昨天恢復的舊資料） 問題本質：\n有 10 筆資料，下一筆應該用 id=16 但 sequence 只記到 7，會嘗試用 id=8 id=8 已存在 → duplicate key error 受影響的 Sequence Keys user_unlocked_animations_user_lnk_id_seq ⭐ 主要問題 user_unlocked_animations_animation_lnk_id_seq ⭐ 也需修復 user_unlocked_animations_id_seq 也需修復 🛠️ 第六幕：修復 立即修復 -- 修復核心問題：user ↔ unlocked_animation link table SELECT setval( \u0026#39;user_unlocked_animations_user_lnk_id_seq\u0026#39;, (SELECT MAX(id) FROM user_unlocked_animations_user_lnk), true ); -- 將 sequence 設定為當前最大 id -- true = 下次會從 max_id + 1 開始 -- 同樣修復其他相關表 SELECT setval(\u0026#39;user_unlocked_animations_animation_lnk_id_seq\u0026#39;, (SELECT MAX(id) FROM user_unlocked_animations_animation_lnk), true); SELECT setval(\u0026#39;user_unlocked_animations_id_seq\u0026#39;, (SELECT MAX(id) FROM user_unlocked_animations), true); 驗證修復 SELECT (SELECT last_value FROM user_unlocked_animations_user_lnk_id_seq) as seq, (SELECT MAX(id) FROM user_unlocked_animations_user_lnk) as max_id; -- 修復前: seq=7, max_id=15 ❌ -- 修復後: seq=15, max_id=15 ✅ 完美對齊！ 測試功能：\n# 使用者嘗試解鎖動畫 curl -X POST /api/user-animation/unlock -d \u0026#39;{\u0026#34;animationId\u0026#34;: 7}\u0026#39; # ✅ 成功！插入了 id=16 的新記錄 修好了！\n🤔 第七幕：反思 教訓 1：緊急狀況下更要冷靜 當時心裡有疑慮：「舊資料插回去，新資料又在進來\u0026hellip;會不會有問題？」\n但在壓力下選擇了「先做再說」。\n當時的實際狀況：\n🔥 上線期間，約 500 人同時註冊 🔥 舊資料正在灌入（手動指定 ID） 🔥 新用戶同時在解鎖動畫（系統自動生成 ID） 🔥 兩個寫入流程在 race condition 中競爭！ 這就像在高速公路上換輪胎 🚗💨，非常危險：\n無法預測 sequence 的最終值 可能產生資料不一致 難以追蹤哪些是舊資料、哪些是新資料 更好的做法：\n開啟維護模式（停止寫入） ⭐ 最關鍵！ 恢復資料 更新 sequence ⭐ 驗證後再開放 為什麼維護模式這麼重要？\n// ❌ 當時的做法（危險） // 500 人在線，新舊資料同時寫入 await restoreOldData(); // 舊資料：id=1~8（手動） // 同時有人解鎖 → sequence 自動生成 id=9,10,11... // 💥 無法確保資料一致性 // ✅ 正確做法 await enableMaintenanceMode(); // 停止所有寫入 await restoreOldData(); await updateSequence(); // 確保 sequence 正確 await verifyDataIntegrity(); // 驗證 await disableMaintenanceMode(); 損失評估：\n開維護模式：停機 5-10 分鐘 不開維護模式：埋下炸彈，24 小時後爆炸，影響更多用戶 值得嗎？絕對值得。\n或者乾脆不保留原始 ID：\n// 移除 id，讓 PostgreSQL 重新產生 const data = backupData.map(({ id, ...rest }) =\u0026gt; rest); await db.insert(data); // ✅ sequence 會自動更新 教訓 2：CloudWatch 要看對地方 日誌來源 看到的內容 診斷價值 應用層 Unique constraint failed 🟡 知道有錯，但不知根因 資料庫層 duplicate key ... Key (id)=(8) already exists 🟢 精確的錯誤資訊 關鍵：\n資料庫錯誤要看資料庫 log (/aws/rds/.../postgresql) 用資料庫術語搜尋 (duplicate key)，不要用抽象詞 (error) 教訓 3：指定 ID 插入後，必須更新 Sequence -- ❌ 直接指定 ID（sequence 不會更新） INSERT INTO table (id, name) VALUES (1, \u0026#39;foo\u0026#39;), (2, \u0026#39;bar\u0026#39;); -- ✅ 插入後更新 sequence SELECT setval(\u0026#39;table_id_seq\u0026#39;, (SELECT MAX(id) FROM table), true); 教訓 4：了解 Strapi 的 Link Tables Strapi 的多對多關係會自動產生 *_lnk 表，它們也有 sequence！\nusers_permissions_user ↓ user_unlocked_animations_user_lnk ← 自動產生，有 sequence ↓ user_unlocked_animations ↓ user_unlocked_animations_animation_lnk ← 也有 sequence 恢復資料時，這些「看不見」的表也要處理！\n🛡️ 第八幕：如何避免重蹈覆轍 1. 建立資料恢復 SOP ## 緊急資料恢復流程 1. [ ] 開啟維護模式 2. [ ] 恢復資料 3. [ ] 更新所有相關表的 sequence ⭐ 4. [ ] 驗證 sequence \u0026gt;= MAX(id) 5. [ ] 測試插入功能 6. [ ] 關閉維護模式 2. 建立安全恢復腳本 // 方法 A: 不保留 ID（推薦） const data = backupData.map(({ id, ...rest }) =\u0026gt; rest); await db.insert(data); // 方法 B: 保留 ID + 更新 sequence await db.insert(backupData); await db.raw( `SELECT setval(\u0026#39;table_id_seq\u0026#39;, (SELECT MAX(id) FROM table), true)` ); 3. 啟動時自動檢查 // config/functions/bootstrap.js async function checkSequences() { const result = await db.raw(` SELECT last_value as seq, (SELECT MAX(id) FROM table) as max_id FROM table_id_seq `); if (result.seq \u0026lt; result.max_id) { throw new Error(\u0026#34;Sequence 不同步！\u0026#34;); } } 4. 設定 CloudWatch 監控 # 監控 duplicate key 錯誤 FilterPattern: \u0026#39;[..., level=ERROR*, msg=\u0026#34;*duplicate key*\u0026#34;]\u0026#39; Alarm: \u0026gt;= 1 error in 5 minutes → Send alert 📊 事後檢討 時間軸 時間 事件 影響 Day 1, 10:30 誤部署舊 commit 🔴 解鎖記錄消失 Day 1, 3:00 恢復資料（未更新 sequence） 🟡 埋下炸彈 Day 2, 15:30 使用者回報解鎖失敗 🔴 炸彈引爆 Day 2, 21:00 發現 sequence 問題 🟢 真相大白 Day 2, 21:30 執行修復 🟢 問題解決 根本原因 Sequence 跟實際資料筆數不同步\n有 10 筆資料（MAX(id)=15） Sequence 只記到 7 下次插入會用 id=8 但 id=8 已存在 💥 Duplicate key error 改善措施 ✅ 資料恢復 SOP（含 sequence 更新） ✅ 安全恢復腳本 ✅ 啟動時自動檢查 sequence ✅ CloudWatch 監控 ✅ 除錯 runbook 💡 給其他開發者的建議 如果你遇到「Unique Constraint」錯誤 # 1. 看資料庫 log，不要只看應用層 /aws/rds/instance/your-db/postgresql # 2. 搜尋: \u0026#34;duplicate key\u0026#34; # 3. 檢查 sequence SELECT last_value FROM table_id_seq; SELECT MAX(id) FROM table; # 如果 last_value \u0026lt; MAX(id) → sequence 問題！ # 4. 修復 SELECT setval(\u0026#39;table_id_seq\u0026#39;, (SELECT MAX(id) FROM table), true); 如果你需要恢復/匯入資料 // ✅ 推薦：不保留 ID const data = backup.map(({ id, ...rest }) =\u0026gt; rest); // ⚠️ 如果保留 ID，記得更新 sequence await db.raw( `SELECT setval(\u0026#39;table_id_seq\u0026#39;, (SELECT MAX(id) FROM table), true)` ); 如果你使用 Strapi 記住：*_lnk 表也有 sequence！\n-- 找出所有 link tables SELECT table_name FROM information_schema.tables WHERE table_name LIKE \u0026#39;%_lnk\u0026#39;; -- 這些表的 sequence 也要檢查更新！ 🎬 尾聲 這次事件讓我學到：\n緊急不等於草率\n心裡有疑慮時，寧可多想 5 分鐘 了解底層機制\nStrapi 的魔法背後有很多自動產生的表 PostgreSQL 的 sequence 不會自動同步 直接指定 ID 插入時，sequence 不會更新 查錯要找對地方\n資料庫錯誤 → 看資料庫 log 用精確的關鍵字搜尋 預防勝於治療\n自動化檢查 \u0026gt; 手動檢查 監控 alert \u0026gt; 使用者回報 希望這篇文章能幫你少走彎路。\n📚 延伸資源 PostgreSQL: Sequence Functions Strapi v5: Database Relations AWS CloudWatch Logs 標籤: #PostgreSQL #Sequence #Strapi #CloudWatch #Production #Debugging\n撰寫日期: 2025-11-09 事件日期: 2025-11-05 影響時間: ~24 小時 Sequence Keys: user_unlocked_animations_user_lnk_id_seq, user_unlocked_animations_animation_lnk_id_seq 學到的教訓: 無價 💎\n","permalink":"https://peter-notes.com/posts/postgresql-sequence-disaster/","summary":"\u003cblockquote\u003e\n\u003cp\u003e「老闆，用戶的解鎖記錄全不見了！」「快把舊資料拉出來灌回去！」在緊急狀況下，我沒想太多就照做了。然後，我不小心埋下了一顆定時炸彈\u0026hellip;\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://img.shields.io/badge/PostgreSQL-Sequence-blue\"\u003e \u003cimg loading=\"lazy\" src=\"https://img.shields.io/badge/Strapi-v5-green\"\u003e \u003cimg loading=\"lazy\" src=\"https://img.shields.io/badge/AWS-CloudWatch-orange\"\u003e \u003cimg loading=\"lazy\" src=\"https://img.shields.io/badge/Status-Production_Incident-red\"\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"-第一幕災難降臨\"\u003e🔥 第一幕：災難降臨\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003e2025 年 11 月某日，上午 10:30\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eSlack 突然炸開：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e💬 \u003cstrong\u003e同事\u003c/strong\u003e：「完蛋了\u0026hellip;我剛剛不小心部署到舊的 commit\u0026hellip;」\u003c/p\u003e\n\u003cp\u003e💬 \u003cstrong\u003eQA\u003c/strong\u003e：「欸！為什麼使用者的動畫解鎖記錄都不見了？」\u003c/p\u003e\n\u003cp\u003e💬 \u003cstrong\u003e使用者\u003c/strong\u003e：「我昨天才花金幣解鎖的動畫怎麼不見了？」\u003c/p\u003e\n\u003cp\u003e💬 \u003cstrong\u003e老闆\u003c/strong\u003e：「@所有人 立刻確認影響範圍！」\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e我打開資料庫一看：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-sql\" data-lang=\"sql\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eSELECT\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eCOUNT\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e*\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eFROM\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003euser_unlocked_animations\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"c1\"\u003e-- 結果: 0 😱 所有用戶的解鎖記錄全部消失！\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e原因\u003c/strong\u003e：同事不小心部署了一個\u003cstrong\u003e舊的 Strapi commit\u003c/strong\u003e，那個版本的 database migration 把 \u003ccode\u003euser_unlocked_animations\u003c/code\u003e 相關的表全部清空了。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"-第二幕老闆的緊急命令\"\u003e⚡ 第二幕：老闆的緊急命令\u003c/h2\u003e\n\u003cblockquote\u003e\n\u003cp\u003e💬 \u003cstrong\u003e老闆\u003c/strong\u003e：「快！把之前的用戶解鎖記錄拉出來，灌回現在的資料庫！」\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e我心裡想：「舊資料插回去，新資料又同時在進來\u0026hellip;會不會有問題？」\u003c/p\u003e\n\u003cp\u003e但老闆在等，使用者在抱怨，沒時間多想，先恢復資料再說。\u003c/p\u003e\n\u003ch3 id=\"緊急恢復資料\"\u003e緊急恢復資料\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// 從備份拉出資料，直接插入（包含原始的 ID）\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"nx\"\u003eblablablabla\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// ⚠️ 直接指定了 id，但沒想到要更新 sequence...\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e執行完畢：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e✅ 資料恢復完成！\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eQA 測試：「使用者的解鎖記錄都回來了！」\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e眾人鬆了一口氣。\u003c/strong\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"-第三幕24-小時後炸彈引爆\"\u003e💣 第三幕：24 小時後，炸彈引爆\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003e隔天下午\u003c/strong\u003e\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e💬 \u003cstrong\u003e客服\u003c/strong\u003e：「有使用者回報說無法解鎖動畫!!!」\u003c/p\u003e\n\u003cp\u003e💬 \u003cstrong\u003e使用者\u003c/strong\u003e：「我有 3 個金幣，想解鎖動畫，但一直顯示錯誤！金幣被扣了但動畫沒解鎖！」\u003c/p\u003e","title":"一次錯誤部署引發的 PostgreSQL Sequence 災難：為什麼使用者突然無法解鎖動畫？"},{"content":" 系列文章第 4 篇（完結篇）：整理所有實用技巧、最佳實踐和疑難排解方法，並提供 30 個立即可用的 Skill 靈感。\n前言 歡迎來到「Claude Code Skills 完全指南」系列的最終篇！\n在前三篇文章中，我們從概念到實戰，從基礎到進階，全面探索了 Skills 系統。今天，讓我們將這些知識轉化為實用的指引和建議。\n你將獲得：\n✅ 完整的最佳實踐清單 ✅ 常見問題與解決方案 ✅ 除錯和優化技巧 ✅ 30 個實用 Skill 靈感 ✅ 可直接使用的完整模板 預計閱讀時間：15 分鐘\n讓我們開始吧！\n最佳實踐指南 1. 命名規範 Skill 名稱 ✅ 好的命名：\nname: pdf-editor name: api-tester name: blog-writer name: code-reviewer ❌ 避免的命名：\nname: PDF Editor # 不要用空格和大寫 name: apiTester # 不要用駝峰式命名 name: Blog_Writer # 不要用底線 name: skill-1 # 不要用無意義的名稱 規則：\n全部小寫 使用連字號分隔單字 描述性且清楚 長度適中（2-4 個單字） 檔案命名 # Scripts ✅ generate_report.py ✅ run_tests.sh ✅ check_format.py ❌ GenerateReport.py ❌ runTests.sh ❌ script1.py # References ✅ api-documentation.md ✅ style-guide.md ✅ database-schema.md ❌ ApiDocumentation.md ❌ styleGuide.md ❌ docs.md # Assets ✅ company-logo.png ✅ report-template.html ✅ font-regular.ttf ❌ Company Logo.png ❌ template.html ❌ file.ttf 2. Description 撰寫技巧 Description 決定 Skill 何時被觸發，非常重要！\n優秀 Description 的特質 1. 詳細說明功能\n❌ 太簡略：\ndescription: Helps with PDFs ✅ 詳細明確：\ndescription: 綜合 PDF 操作工具，支援提取文字和表格、建立新 PDF、合併/分割文件、旋轉頁面、處理表單。當需要程式化操作 PDF 檔案、提取 PDF 內容、或修改 PDF 文件時使用此 Skill。 2. 包含使用時機\n✅ 明確的觸發條件：\ndescription: 自動化 API 整合測試工具。用於執行微服務間的整合測試、驗證 API 回應、產生測試報告。適用於 CI/CD 流程、部署前驗證、或手動測試需求。當需要測試多個 API 端點、驗證服務間通訊時使用。 3. 使用第三人稱\n❌ 第二人稱：\ndescription: Use this skill when you want to create blog posts ✅ 第三人稱：\ndescription: 此 Skill 應用於需要撰寫技術部落格文章時 4. 包含關鍵字\ndescription: 程式碼審查助手。自動檢查程式碼風格、潛在錯誤、效能問題、安全漏洞。支援 JavaScript、Python、Go、Rust。當需要 code review、檢查程式碼品質、辨識 bug、或執行靜態分析時使用此 Skill。 關鍵字：code review, 程式碼品質, bug, 靜態分析, JavaScript, Python\n3. SKILL.md 撰寫風格 使用命令式/不定式 ❌ 避免第二人稱：\nYou should check the API documentation before making requests. If you want to test an endpoint, you can use the test script. ✅ 使用命令式：\nTo make API requests, consult `references/api-docs.md` for endpoint specifications. To test endpoints, execute `scripts/test_endpoint.py` with required parameters. 保持客觀和指導性 ❌ 主觀或不確定：\nYou might want to consider using the script if you feel like it. This is probably the best way to do it. ✅ 客觀和明確：\nUse `scripts/process_data.py` for batch data processing. This approach ensures data consistency and reduces processing time by 70%. 結構化內容 # Skill 名稱 ## 目的 [2-3 句話說明用途] ## 使用時機 [清楚的使用場景列表] ## 快速開始 [最簡單的使用範例] ## 操作指引 [詳細的步驟] ## 檔案說明 [說明各個 scripts/references/assets 的用途] ## 範例 [實際使用範例] ## 疑難排解 [常見問題和解決方案] 4. Scripts vs References vs Assets 何時使用哪一種？這是最常被問到的問題。\n決策樹 你要包含的內容是？ ├─ 可執行的程式碼 │ ├─ 會被重複撰寫 → Scripts │ ├─ 需要確定性執行 → Scripts │ └─ 運算密集 → Scripts │ ├─ 參考資訊/文件 │ ├─ API 文件 → References │ ├─ 資料庫 Schema → References │ ├─ 規範指南 → References │ └─ 範例和最佳實踐 → References │ └─ 輸出用檔案 ├─ 模板（會被複製/修改）→ Assets ├─ 品牌素材（Logo、字型）→ Assets └─ 起始專案 → Assets 實例對比 場景：處理 PDF 旋轉\n❌ 不好的做法（放在 References）：\nreferences/pdf-rotation-code.md # PDF 旋轉程式碼 \\`\\`\\`python from PyPDF2 import PdfReader, PdfWriter def rotate_pdf(input_path, output_path, degrees): reader = PdfReader(input_path) writer = PdfWriter() # ... 200 行程式碼 \\`\\`\\` ✅ 好的做法（製作成 Script）：\nscripts/rotate_pdf.py #!/usr/bin/env python3 # 可直接執行，不佔用上下文 場景：公司 API 端點清單\n❌ 不好的做法（放在 SKILL.md）：\n# SKILL.md ## API Endpoints ### User Service - GET /api/users - POST /api/users ... (列舉 100 個端點) ✅ 好的做法（放在 References）：\n# SKILL.md ## API Endpoints 參考 `references/api-endpoints.md` 獲取完整 API 清單。 使用 grep 搜尋特定服務： \\`\\`\\`bash grep \u0026#34;user-service\u0026#34; references/api-endpoints.md \\`\\`\\` 5. 大型 References 的處理 當 reference 檔案很大（\u0026gt;10k 字）時：\n提供搜尋指引 ## 使用資料庫 Schema `references/database-schema.md` 包含所有資料表定義（約 15,000 字）。 **不要完整讀取**，使用這些搜尋模式： \\`\\`\\`bash # 搜尋特定資料表 grep -A 20 \u0026#34;## users table\u0026#34; references/database-schema.md # 搜尋特定欄位 grep -B 5 \u0026#34;email_address\u0026#34; references/database-schema.md # 搜尋關聯 grep \u0026#34;foreign key\u0026#34; references/database-schema.md \\`\\`\\` **常用資料表：** - users - 使用者基本資訊 - orders - 訂單記錄 - products - 產品目錄 - payments - 支付資訊 分割大型檔案 ❌ 不好的做法：\nreferences/ └── everything.md (50,000 字) ✅ 好的做法：\nreferences/ ├── api-authentication.md ├── api-users.md ├── api-orders.md ├── api-payments.md └── api-common-errors.md 常見問題與疑難排解 Q1: Skill 沒有被觸發？ 症狀 你：幫我處理這個 PDF Claude：好的，讓我來處理...（但沒有使用 PDF skill） 可能原因與解決方案 原因 1：Description 不夠明確\n❌ 問題：\ndescription: A skill for PDFs ✅ 解決：\ndescription: PDF 操作工具，支援提取文字、合併、分割、旋轉頁面。當需要處理 PDF 檔案、提取 PDF 內容、或修改 PDF 文件時使用此 Skill。 原因 2：使用者的提問方式不匹配\n💡 解決：明確提及 Skill\n❌ 「幫我處理這個檔案」 ✅ 「請使用 PDF skill 提取這個檔案的文字」 原因 3：Skill 未正確安裝\n# 檢查已安裝的 Skills /plugin list # 確認 Skill 在列表中 # 如果沒有，重新安裝 /plugin install /path/to/skill.zip Q2: 上下文視窗不夠用？ 症狀 Error: Context window exceeded 解決方案 1. 精簡 SKILL.md\n❌ 太長：\n# SKILL.md (10,000 字) ## API Endpoints [列舉 100 個端點的完整文件...] ## Database Schema [列舉 50 個資料表的完整定義...] ✅ 精簡：\n# SKILL.md (2,000 字) ## API Endpoints 參考 `references/api-endpoints.md` 使用 grep 搜尋特定端點。 ## Database Schema 參考 `references/database-schema.md` 使用搜尋模式找到需要的資料表。 2. 將程式碼移到 Scripts\n❌ 在 SKILL.md 中包含大量程式碼：\n## 處理圖片 使用以下程式碼： \\`\\`\\`python # 500 行圖片處理程式碼... \\`\\`\\` ✅ 製作成 Script：\n## 處理圖片 執行： \\`\\`\\`bash python scripts/process_image.py --input image.jpg --output result.jpg \\`\\`\\` 3. 避免重複內容\n檢查 SKILL.md 和 references 是否有重複：\n# 檢查重複 diff SKILL.md references/guide.md Q3: Script 執行失敗？ 常見錯誤 1：缺少相依套件 錯誤訊息：\nModuleNotFoundError: No module named \u0026#39;requests\u0026#39; 解決方案：\n建立 requirements.txt：\nrequests==2.31.0 pandas==2.1.0 beautifulsoup4==4.12.0 在 SKILL.md 中說明：\n## 安裝相依套件 \\`\\`\\`bash pip install -r requirements.txt \\`\\`\\` 在 Script 中加入檢查：\n#!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34; 需求： - Python 3.8+ - pip install -r requirements.txt \u0026#34;\u0026#34;\u0026#34; import sys # 檢查相依套件 try: import requests import pandas as pd except ImportError as e: print(f\u0026#34;錯誤：缺少必要套件\u0026#34;, file=sys.stderr) print(f\u0026#34;請執行：pip install -r requirements.txt\u0026#34;, file=sys.stderr) sys.exit(1) 常見錯誤 2：路徑問題 錯誤訊息：\nFileNotFoundError: [Errno 2] No such file or directory: \u0026#39;data.json\u0026#39; 解決方案：\n使用絕對路徑或相對於腳本的路徑：\nimport os from pathlib import Path # 取得腳本所在目錄 SCRIPT_DIR = Path(__file__).parent SKILL_DIR = SCRIPT_DIR.parent # 使用相對路徑 data_file = SKILL_DIR / \u0026#34;assets\u0026#34; / \u0026#34;data.json\u0026#34; 常見錯誤 3：權限不足 錯誤訊息：\nPermissionError: [Errno 13] Permission denied 解決方案：\n# 確保腳本可執行 chmod +x scripts/*.py # 確保輸出目錄可寫入 mkdir -p output chmod 755 output Q4: 如何除錯 Skill？ 技巧 1：加入 Verbose 模式 # scripts/my_script.py import argparse parser = argparse.ArgumentParser() parser.add_argument(\u0026#39;--verbose\u0026#39;, \u0026#39;-v\u0026#39;, action=\u0026#39;store_true\u0026#39;, help=\u0026#39;詳細輸出\u0026#39;) args = parser.parse_args() def log(message, level=\u0026#39;INFO\u0026#39;): if args.verbose or level == \u0026#39;ERROR\u0026#39;: print(f\u0026#34;[{level}] {message}\u0026#34;, file=sys.stderr) log(\u0026#34;正在載入設定檔...\u0026#34;, \u0026#39;DEBUG\u0026#39;) log(\u0026#34;處理完成\u0026#34;, \u0026#39;INFO\u0026#39;) 使用：\npython scripts/my_script.py --verbose 技巧 2：在 SKILL.md 加入疑難排解 ## 疑難排解 ### Script 執行失敗 1. **檢查相依套件：** \\`\\`\\`bash pip list | grep -E \u0026#34;(requests|pandas)\u0026#34; \\`\\`\\` 2. **啟用詳細模式：** \\`\\`\\`bash python scripts/run_tests.py --verbose \\`\\`\\` 3. **查看日誌：** \\`\\`\\`bash tail -f logs/skill.log \\`\\`\\` ### 常見錯誤訊息 #### \u0026#34;ModuleNotFoundError\u0026#34; **原因：** 缺少 Python 套件 **解決：** `pip install -r requirements.txt` #### \u0026#34;Permission denied\u0026#34; **原因：** 腳本沒有執行權限 **解決：** `chmod +x scripts/*.py` #### \u0026#34;File not found\u0026#34; **原因：** 路徑不正確 **解決：** 確認檔案存在，使用絕對路徑 技巧 3：單元測試 建立測試腳本：\n# tests/test_script.py import unittest from scripts.generate_report import generate_report class TestReportGeneration(unittest.TestCase): def test_basic_report(self): result = generate_report(data={\u0026#39;test\u0026#39;: 123}) self.assertIn(\u0026#39;test\u0026#39;, result) def test_empty_data(self): with self.assertRaises(ValueError): generate_report(data={}) if __name__ == \u0026#39;__main__\u0026#39;: unittest.main() Q5: 如何版本控制 Skills？ 在 Frontmatter 加入版本資訊 --- name: my-skill description: ... version: 2.1.0 author: Your Name changelog: | v2.1.0 (2025-01-20): - 新增：批次處理功能 - 修正：UTF-8 編碼問題 - 優化：效能提升 40% v2.0.0 (2025-01-01): - 重大更新：重寫核心邏輯 - 新增：支援新的檔案格式 - 移除：deprecated API v1.0.0 (2024-12-01): - 初版發布 --- 使用 Git Tags # 建立版本標籤 git tag -a v2.1.0 -m \u0026#34;Release version 2.1.0\u0026#34; git push origin v2.1.0 # 檢出特定版本 git checkout v2.0.0 # 列出所有版本 git tag -l 語意化版本規則 版本格式：MAJOR.MINOR.PATCH MAJOR：不相容的 API 變更 MINOR：向下相容的新功能 PATCH：向下相容的錯誤修正 範例： 1.0.0 → 1.0.1 (Bug 修正) 1.0.1 → 1.1.0 (新增功能) 1.1.0 → 2.0.0 (重大變更) 30 個實用 Skill 靈感清單 這裡是我整理的 30 個實用 Skill 概念，涵蓋不同領域：\n開發工具（Development） 1. code-reviewer 功能：自動程式碼審查 - 檢查程式碼風格 - 辨識常見錯誤模式 - 建議效能優化 - 檢查安全漏洞 2. git-commit-helper 功能：產生符合規範的 commit message - 分析程式碼變更 - 產生語意化 commit message - 支援 Conventional Commits 格式 3. test-generator 功能：自動產生單元測試 - 分析函數簽名 - 產生測試案例 - 包含 edge cases - 支援多種測試框架 4. api-doc-generator 功能：從程式碼產生 API 文件 - 解析程式碼註解 - 產生 OpenAPI/Swagger 規格 - 包含範例請求/回應 5. dependency-updater 功能：檢查並更新相依套件 - 檢查過期套件 - 辨識安全漏洞 - 產生更新建議 - 生成更新 PR 內容創作（Content Creation） 6. social-media-scheduler 功能：多平台社群貼文產生器 - 針對不同平台最佳化 - 自動產生 hashtags - 建議發文時間 - 包含配圖建議 7. newsletter-writer 功能：電子報內容產生器 - 套用公司模板 - 分段和格式化 - 包含 CTA - 產生純文字版本 8. seo-optimizer 功能：SEO 優化建議 - 關鍵字分析 - 標題優化建議 - Meta description 產生 - 內部連結建議 9. video-script-writer 功能：影片腳本產生器 - 結構化腳本格式 - 時間碼標記 - 畫面描述 - 旁白文字 10. podcast-show-notes 功能：Podcast 節目筆記產生器 - 時間戳章節標記 - 重點摘要 - 資源連結整理 - 逐字稿格式化 設計相關（Design） 11. color-palette-generator 功能：配色方案產生器 - 根據品牌色產生色票 - 檢查對比度 - 提供 HEX/RGB 代碼 - 產生預覽圖 12. figma-to-code 功能：Figma 設計轉程式碼 - 分析設計稿 - 產生 HTML/CSS - React 元件產生 - 響應式設計支援 13. accessibility-checker 功能：無障礙設計檢查 - 色彩對比檢查 - 文字大小檢查 - 鍵盤導航測試 - ARIA 標籤建議 14. icon-generator 功能：圖示產生器 - 多尺寸輸出 - 不同格式（SVG, PNG） - 符合設計系統 - favicon 產生 15. design-system-docs 功能：設計系統文件產生器 - 元件文件 - 使用範例 - 程式碼片段 - 互動示範 資料處理（Data Processing） 16. csv-analyzer 功能：CSV 資料分析工具 - 自動偵測欄位類型 - 基本統計分析 - 資料視覺化 - 異常值偵測 17. json-validator 功能：JSON 格式驗證與轉換 - Schema 驗證 - 格式化和美化 - JSON ↔ YAML 轉換 - 錯誤訊息說明 18. log-analyzer 功能：日誌檔案分析工具 - 錯誤模式辨識 - 效能瓶頸分析 - 時間序列圖表 - 異常偵測 19. data-cleaner 功能：資料清理與標準化 - 移除重複項 - 格式標準化 - 缺失值處理 - 資料驗證 20. report-generator 功能：自動化報表產生 - 資料聚合 - 圖表生成 - 套用模板 - 多格式輸出（PDF, Excel） 專案管理（Project Management） 21. sprint-planner 功能：Sprint 規劃助手 - 工作量估算 - 任務優先順序排序 - 產生 Sprint 目標 - 風險識別 22. meeting-notes 功能：會議記錄產生器 - 自動產生會議記錄 - 提取行動項目 - 指派負責人 - 追蹤待辦事項 23. project-status-updater 功能：專案狀態報告 - 進度追蹤 - 風險標記 - 里程碑檢查 - 利害關係人報告 24. time-tracker-analyzer 功能：時間追蹤分析 - 時間分布分析 - 生產力報告 - 專案成本計算 - 時間最佳化建議 25. retrospective-facilitator 功能：回顧會議引導 - 產生討論問題 - 整理回饋意見 - 識別改進項目 - 追蹤行動計畫 客戶服務（Customer Service） 26. email-responder 功能：客服郵件回覆助手 - 自動分類郵件 - 產生回覆範本 - 情緒分析 - 優先順序排序 27. faq-generator 功能：常見問題產生器 - 從文件提取 FAQ - 分類和組織 - 產生答案 - 搜尋優化 28. support-ticket-analyzer 功能：客服單分析 - 問題分類 - 趨勢分析 - 回應時間追蹤 - 客戶滿意度分析 29. customer-feedback-analyzer 功能：客戶回饋分析 - 情緒分析 - 主題萃取 - 優先順序排序 - 改進建議產生 30. knowledge-base-builder 功能：知識庫建立器 - 文章結構化 - 標籤和分類 - 搜尋最佳化 - 相關文章連結 完整的 Skill 模板 這是一個可以直接使用的完整模板：\n--- name: skill-name description: 詳細說明此 Skill 的功能和使用時機。使用第三人稱，包含關鍵字，說明何時應該使用此 Skill。 version: 1.0.0 author: Your Name license: MIT changelog: | v1.0.0 (2025-01-01): - 初版發布 - 實作基本功能 --- # Skill 名稱 ## 目的 [2-3 句話說明這個 Skill 的用途和價值] ## 使用時機 此 Skill 應該在以下情況使用： - [情境 1] - [情境 2] - [情境 3] ## 快速開始 \\`\\`\\`bash # 最基本的使用方式 [command example] # 常用選項 [command with options] \\`\\`\\` ## 安裝與設定 ### 相依套件 \\`\\`\\`bash # 安裝 Python 套件 pip install -r requirements.txt # 或使用 npm npm install \\`\\`\\` ### 環境變數 \\`\\`\\`bash # 複製環境變數範本 cp .env.example .env # 編輯必要的設定 # API_KEY=your-key-here # API_URL=https://api.example.com \\`\\`\\` ## 檔案說明 ### Scripts #### scripts/main_script.py [說明主要腳本的功能] **用法：** \\`\\`\\`bash python scripts/main_script.py --help \\`\\`\\` **參數：** - `--input`: 輸入檔案路徑 - `--output`: 輸出檔案路徑 - `--verbose`: 詳細輸出 **範例：** \\`\\`\\`bash python scripts/main_script.py --input data.json --output result.json \\`\\`\\` ### References #### references/guide.md [說明參考文件內容和如何使用] 由於檔案較大，建議使用 grep 搜尋： \\`\\`\\`bash grep \u0026#34;關鍵字\u0026#34; references/guide.md \\`\\`\\` ### Assets #### assets/template.xyz [說明素材檔案用途和如何使用] ## 詳細操作指引 ### 步驟 1: [標題] [詳細說明第一步] \\`\\`\\`bash # 指令範例 command example \\`\\`\\` ### 步驟 2: [標題] [詳細說明第二步] ## 使用範例 ### 範例 1: [基本場景] **需求：** [描述需求] **操作：** \\`\\`\\`bash [command] \\`\\`\\` **預期輸出：** \\`\\`\\` [output] \\`\\`\\` ### 範例 2: [進階場景] **需求：** [描述需求] **操作：** \\`\\`\\`bash [command with advanced options] \\`\\`\\` ## 最佳實踐 ### 1. [建議 1] [詳細說明] ### 2. [建議 2] [詳細說明] ### 3. [建議 3] [詳細說明] ## 疑難排解 ### 常見問題 #### Q: [問題] **A:** [答案] #### Q: [問題] **A:** [答案] ### 錯誤訊息 #### \u0026#34;Error message 1\u0026#34; **原因：** [說明原因] **解決：** \\`\\`\\`bash [solution command] \\`\\`\\` #### \u0026#34;Error message 2\u0026#34; **原因：** [說明原因] **解決：** \\`\\`\\`bash [solution command] \\`\\`\\` ## 進階設定 ### [進階功能 1] [說明和設定方式] ### [進階功能 2] [說明和設定方式] ## 限制與注意事項 - ⚠️ [限制 1] - ⚠️ [限制 2] - ⚠️ [限制 3] ## 更新日誌 詳細的變更記錄請參考 frontmatter 中的 changelog。 ## 貢獻 歡迎貢獻改進！請： 1. Fork 此 Skill 2. 建立功能分支 3. 提交變更 4. 發送 Pull Request ## 授權 [授權資訊，例如 MIT License] ## 相關資源 - [相關文件連結] - [官方網站] - [GitHub Repository] Skill 品質檢查清單 建立 Skill 後，使用這個清單驗證品質：\n## 元數據檢查 - [ ] name 使用小寫和連字號 - [ ] description 詳細且包含使用時機 - [ ] description 使用第三人稱 - [ ] 包含版本號 - [ ] 包含作者資訊 ## SKILL.md 檢查 - [ ] 清楚說明 Skill 的目的 - [ ] 說明何時應該使用此 Skill - [ ] 提供快速開始範例 - [ ] 包含詳細操作指引 - [ ] 參照所有 scripts/references/assets - [ ] 使用命令式語氣 - [ ] 沒有與 references 重複的內容 - [ ] 包含疑難排解章節 ## Scripts 檢查 - [ ] 所有 script 都可執行（chmod +x） - [ ] 包含 --help 參數 - [ ] 有清楚的錯誤訊息 - [ ] 檢查相依套件 - [ ] 在 SKILL.md 中說明如何使用 - [ ] 包含使用範例 ## References 檢查 - [ ] 內容組織良好 - [ ] 大檔案有搜尋指引 - [ ] 與 SKILL.md 沒有重複 - [ ] 格式化清楚（Markdown） - [ ] 包含實用範例 ## Assets 檢查 - [ ] 檔案完整可用 - [ ] 在 SKILL.md 中說明用途 - [ ] 適當的檔案格式 - [ ] 檔案大小合理 ## 測試檢查 - [ ] 用實際案例測試過 - [ ] Scripts 執行正常 - [ ] 錯誤處理適當 - [ ] 文件路徑正確 ## 打包檢查 - [ ] 資料夾結構正確 - [ ] 可成功打包成 .zip - [ ] 檔案權限正確 - [ ] 包含必要的 requirements.txt 或 package.json ## 文件檢查 - [ ] README 或 SKILL.md 清楚 - [ ] 包含範例 - [ ] 包含疑難排解 - [ ] 更新日誌完整 系列總結 經過四篇文章的探索，我們完整了解了 Claude Code Skills：\n第 1 篇：入門篇 ✅ Skills 的核心概念 ✅ 為什麼需要 Skills ✅ 漸進式揭露設計原則 第 2 篇：實戰篇 ✅ 建立第一個 Skill ✅ 完整的開發流程 ✅ 實用的範例專案 第 3 篇：進階篇 ✅ 企業級應用 ✅ 官方範例解析 ✅ Skills 組合使用 第 4 篇：實用技巧篇（本篇） ✅ 最佳實踐 ✅ 疑難排解 ✅ 30 個靈感清單 ✅ 完整模板 下一步行動 立即開始 選擇一個靈感 - 從 30 個靈感中挑選一個 使用模板 - 複製完整模板開始建立 測試和迭代 - 實際使用並持續改進 進階學習 探索官方範例 - https://github.com/anthropics/skills 加入社群 - https://www.anthropic.com/discord 分享你的 Skills - 貢獻到開源社群 持續改進 📊 追蹤 Skill 的使用情況 📝 記錄改進點 🔄 定期更新和優化 👥 收集使用者回饋 📚 系列文章導航 第 1 篇：入門篇 - 理解 Skills 核心概念 第 2 篇：實戰篇 - 建立第一個 Skill 第 3 篇：進階篇 - 企業級應用與官方範例 [第 4 篇：實用技巧篇] ← 你在這裡 結語 感謝你跟隨這個系列到最後！\nSkills 不只是一個功能，它代表了：\n🎯 知識的固化 - 將經驗轉化為可重用的工具 ⚡ 效率的提升 - 自動化重複性工作 🤝 協作的增強 - 團隊知識共享 🚀 創新的可能 - 探索 AI 協作的新方式 現在輪到你了！\n開始建立你的第一個 Skill，並在留言區分享你的成果和經驗。\n如果這個系列對你有幫助：\n👏 給所有文章拍手支持 🔖 收藏以便日後參考 👤 追蹤我，獲取更多 AI 工具教學 📤 分享給需要的朋友 💬 最後的問候 你打算建立什麼樣的 Skill？\n有任何問題或想法，歡迎留言討論！我會盡力回答。\n祝你在 AI 協作的旅程中，創造出驚人的成果！\n相關資源\nAnthropic Skills GitHub Claude Code 官方文件 Skills API 指南 Discord 社群 完整程式碼範例（GitHub） 關於作者\n我是一位熱衷於探索 AI 工具的開發者。這個系列是我深入研究 Claude Code Skills 數週後的完整心得，希望能幫助更多人掌握這個強大的工具。\n如果你想看到更多類似的教學內容，歡迎追蹤我！\n標籤：Claude Code, Skills, 最佳實踐, 疑難排解, AI, 自動化, 教學, 完結篇\n","permalink":"https://peter-notes.com/posts/claude-skills-best-practices/","summary":"\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e系列文章第 4 篇（完結篇）\u003c/strong\u003e：整理所有實用技巧、最佳實踐和疑難排解方法，並提供 30 個立即可用的 Skill 靈感。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e歡迎來到「Claude Code Skills 完全指南」系列的最終篇！\u003c/p\u003e\n\u003cp\u003e在\u003ca href=\"%E9%80%A3%E7%B5%90\"\u003e前三篇文章\u003c/a\u003e中，我們從概念到實戰，從基礎到進階，全面探索了 Skills 系統。今天，讓我們將這些知識轉化為實用的指引和建議。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e你將獲得：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e✅ 完整的最佳實踐清單\u003c/li\u003e\n\u003cli\u003e✅ 常見問題與解決方案\u003c/li\u003e\n\u003cli\u003e✅ 除錯和優化技巧\u003c/li\u003e\n\u003cli\u003e✅ 30 個實用 Skill 靈感\u003c/li\u003e\n\u003cli\u003e✅ 可直接使用的完整模板\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e預計閱讀時間：15 分鐘\u003c/p\u003e\n\u003cp\u003e讓我們開始吧！\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"最佳實踐指南\"\u003e最佳實踐指南\u003c/h2\u003e\n\u003ch3 id=\"1-命名規範\"\u003e1. 命名規範\u003c/h3\u003e\n\u003ch4 id=\"skill-名稱\"\u003eSkill 名稱\u003c/h4\u003e\n\u003cp\u003e\u003cstrong\u003e✅ 好的命名：\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003epdf-editor\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eapi-tester\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eblog-writer\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003ecode-reviewer\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e❌ 避免的命名：\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003ePDF Editor       \u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"c\"\u003e# 不要用空格和大寫\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eapiTester        \u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"c\"\u003e# 不要用駝峰式命名\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eBlog_Writer      \u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"c\"\u003e# 不要用底線\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eskill-1          \u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"c\"\u003e# 不要用無意義的名稱\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e規則：\u003c/strong\u003e\u003c/p\u003e","title":"Skills 實用技巧：最佳實踐、疑難排解與 30 個靈感清單"},{"content":" 系列文章第 3 篇：探索企業級 Skills 的設計模式，深度解析官方範例，學習 Skills 組合使用。\n前言 在前兩篇文章中，我們了解了 Skills 的概念並動手建立了第一個 Skill。今天，讓我們更進一步，探索企業級應用和進階技巧。\n你將學到：\n✅ 企業級 Skills 的設計模式 ✅ 官方 Document Skills 深度解析 ✅ Webapp Testing Skill 的偵查後行動模式 ✅ Skills 組合使用策略 ✅ 在不同平台使用 Skills 預計閱讀時間：12 分鐘\n企業級 Skill 範例：API 整合測試器 讓我們看一個更複雜的企業級範例，展示 Skills 在實際業務場景的應用。\n使用場景 問題： 團隊有 10 個微服務，每次部署前都要手動測試 API 整合，耗時且容易出錯。\n解決方案： 建立一個 API 整合測試 Skill\n目錄結構 api-integration-tester/ ├── SKILL.md ├── scripts/ │ ├── run_tests.py # 主要測試執行器 │ ├── generate_report.py # 產生測試報告 │ └── notify_slack.py # Slack 通知 ├── references/ │ ├── api-endpoints.md # API 端點文件 │ ├── test-scenarios.md # 測試情境說明 │ └── auth-guide.md # 認證方式指南 └── assets/ ├── test-data/ # 測試資料 │ ├── valid-requests.json │ └── invalid-requests.json └── report-template.html # 報告模板 SKILL.md 設計重點 --- name: api-integration-tester description: 自動化 API 整合測試工具。此 Skill 用於執行微服務間的整合測試、驗證 API 回應、產生測試報告，並在發現問題時通知團隊。適用於 CI/CD 流程或手動測試需求。 --- # API 整合測試器 ## 快速開始 \\`\\`\\`bash # 執行所有測試 python scripts/run_tests.py --env production # 執行特定服務測試 python scripts/run_tests.py --service user-service --env staging # 產生報告 python scripts/generate_report.py --output ./reports/ \\`\\`\\` ## 測試流程 1. **載入測試情境** - 從 `references/test-scenarios.md` 讀取 2. **準備測試資料** - 使用 `assets/test-data/` 中的資料 3. **執行測試** - 呼叫各 API 端點並驗證回應 4. **產生報告** - 使用 `assets/report-template.html` 5. **通知團隊** - 若有失敗，透過 Slack 通知 ## 重要檔案說明 ### references/api-endpoints.md 包含所有微服務的端點資訊。由於檔案較大，可用 grep 搜尋： \\`\\`\\`bash grep -A 10 \u0026#34;user-service\u0026#34; references/api-endpoints.md \\`\\`\\` ### scripts/run_tests.py 主要測試執行器，支援參數： - `--env`: 環境（development/staging/production） - `--service`: 特定服務名稱 - `--verbose`: 詳細輸出 - `--fail-fast`: 遇到錯誤立即停止 關鍵設計模式 1. 黑盒腳本模式 **Always run scripts with `--help` first** DO NOT read the source until absolutely necessary. 這些腳本很大，會污染上下文視窗。 它們被設計為「黑盒」直接調用，而不是讀取理解。 為什麼這樣設計？\n✅ 節省 token（不載入腳本原始碼） ✅ 確定性執行（腳本已測試過） ✅ 易於維護（邏輯封裝在腳本中） 2. 大型 References 的搜尋模式 ## 使用資料庫 Schema `references/database-schema.md` 包含所有資料表定義（\u0026gt;10k 字）。 **不要完整讀取**，使用 grep 搜尋： \\`\\`\\`bash # 搜尋 users 資料表 grep -A 20 \u0026#34;## users table\u0026#34; references/database-schema.md # 搜尋特定欄位 grep -B 5 \u0026#34;email\u0026#34; references/database-schema.md \\`\\`\\` **常用資料表：** - users - 使用者資訊 - orders - 訂單記錄 - products - 產品目錄 優勢：\n✅ 保持上下文視窗精簡 ✅ Claude 只載入需要的部分 ✅ 支援超大型參考文件 3. 環境感知模式 # scripts/run_tests.py 的設計 def get_api_url(env): \u0026#34;\u0026#34;\u0026#34;根據環境返回正確的 API URL\u0026#34;\u0026#34;\u0026#34; urls = { \u0026#39;development\u0026#39;: \u0026#39;http://localhost:3000\u0026#39;, \u0026#39;staging\u0026#39;: \u0026#39;https://staging-api.company.com\u0026#39;, \u0026#39;production\u0026#39;: \u0026#39;https://api.company.com\u0026#39; } return urls[env] def get_auth_token(env): \u0026#34;\u0026#34;\u0026#34;根據環境載入正確的認證 token\u0026#34;\u0026#34;\u0026#34; if env == \u0026#39;production\u0026#39;: # 從環境變數讀取 return os.getenv(\u0026#39;PROD_API_TOKEN\u0026#39;) else: # 使用測試 token return \u0026#39;test-token-123\u0026#39; 價值：\n同一個 Skill 支援多環境 避免誤操作 production 資料 團隊成員用相同工具但不同環境 官方範例深度解析 Anthropic 提供了豐富的官方範例，讓我們深入分析幾個精華。\n1. Document Skills - DOCX Skill 用途： 建立、編輯和分析 Word 文件\n核心功能 docx-skill/ ├── SKILL.md ├── scripts/ │ ├── create_docx.py # 建立新文件 │ ├── edit_docx.py # 編輯現有文件 │ ├── extract_text.py # 提取文字內容 │ └── track_changes.py # 處理追蹤修訂 └── references/ └── formatting-guide.md # 格式設定指南 學習重點 1. 處理二進位檔案格式\n# scripts/create_docx.py 的核心邏輯 from docx import Document from docx.shared import Pt, RGBColor def create_document(title, content, style=\u0026#39;Normal\u0026#39;): \u0026#34;\u0026#34;\u0026#34;建立格式化的 Word 文件\u0026#34;\u0026#34;\u0026#34; doc = Document() # 加入標題 heading = doc.add_heading(title, level=1) heading.runs[0].font.color.rgb = RGBColor(0x2C, 0x3E, 0x50) # 加入內容 for paragraph in content.split(\u0026#39;\\n\\n\u0026#39;): p = doc.add_paragraph(paragraph) p.style = style return doc 2. 保留格式的重要性\n## 編輯現有文件時的原則 1. **保留現有格式** - 不要改變未修改段落的格式 2. **維持樣式一致** - 使用文件已有的樣式 3. **追蹤修訂** - 啟用追蹤修訂模式（如果需要） 為什麼這很重要？\n企業文件有嚴格的格式要求 協作時需要看到誰改了什麼 避免破壞現有的文件結構 實際應用 使用者：請編輯 contract.docx，將所有「乙方」改為「合作方」，但保留追蹤修訂 Claude 會： 1. 讀取文件 2. 啟用追蹤修訂模式 3. 找到所有「乙方」並替換 4. 保存時保留原格式和修訂記錄 2. Webapp Testing Skill 用途： 使用 Playwright 測試本地網頁應用\n這是我最喜歡的範例之一，設計非常精妙。\n核心概念：「偵查後行動」模式 ## 決策樹：選擇你的方法 User task → Is it static HTML? ├─ Yes → Read HTML file directly │ └─ Write Playwright script │ └─ No (dynamic webapp) → Is server running? ├─ No → Use with_server.py helper │ └─ Write simplified script │ └─ Yes → Reconnaissance-then-action: 1. Navigate and wait for networkidle 2. Take screenshot or inspect DOM 3. Identify selectors 4. Execute actions 偵查後行動（Reconnaissance-Then-Action） 這是處理動態網頁的關鍵模式：\nfrom playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() # Step 1: 導航並等待 page.goto(\u0026#39;http://localhost:5173\u0026#39;) page.wait_for_load_state(\u0026#39;networkidle\u0026#39;) # ⚠️ 關鍵！ # Step 2: 偵查 - 截圖檢視 page.screenshot(path=\u0026#39;/tmp/inspect.png\u0026#39;, full_page=True) # Step 3: 偵查 - 檢查 DOM buttons = page.locator(\u0026#39;button\u0026#39;).all() print(f\u0026#34;找到 {len(buttons)} 個按鈕\u0026#34;) # Step 4: 行動 - 使用發現的選擇器 page.click(\u0026#39;button:has-text(\u0026#34;Submit\u0026#34;)\u0026#39;) browser.close() 為什麼要這樣做？\n## 常見陷阱 ❌ **不要** 在 networkidle 前檢查 DOM → JavaScript 還沒執行完，看到的是空白頁面 ✅ **一定要** 等待 networkidle 再偵查 → 確保頁面完全載入 Helper Script：with_server.py 這個腳本展示了如何管理伺服器生命週期：\n# 單一伺服器 python scripts/with_server.py \\ --server \u0026#34;npm run dev\u0026#34; \\ --port 5173 \\ -- python your_test.py # 多伺服器（前後端分離） python scripts/with_server.py \\ --server \u0026#34;cd backend \u0026amp;\u0026amp; python server.py\u0026#34; --port 3000 \\ --server \u0026#34;cd frontend \u0026amp;\u0026amp; npm run dev\u0026#34; --port 5173 \\ -- python your_test.py 設計亮點：\n✅ 自動啟動和關閉伺服器 ✅ 等待伺服器就緒才執行測試 ✅ 測試結束自動清理 ✅ 支援多伺服器場景 實際應用案例 使用者：測試我的 todo app 的新增功能 Claude 會： 1. 識別需要 webapp-testing skill 2. 使用 with_server.py 啟動開發伺服器 3. 導航到 localhost 4. 等待 networkidle 5. 截圖偵查頁面結構 6. 找到輸入框和新增按鈕 7. 執行測試：輸入文字 → 點擊新增 → 驗證結果 8. 關閉伺服器 3. Brand Guidelines Skill 用途： 套用公司品牌規範到各種輸出\n設計亮點 --- name: brand-guidelines description: 套用 Anthropic 的官方品牌配色和字型到任何 artifact。當需要品牌配色、視覺格式、或公司設計標準時使用。 --- ## Brand Guidelines ### Colors **Main Colors:** - Dark: `#141413` - 主要文字和深色背景 - Light: `#faf9f5` - 淺色背景和深色文字 - Mid Gray: `#b0aea5` - 次要元素 - Light Gray: `#e8e6dc` - 微妙背景 **Accent Colors:** - Orange: `#d97757` - 主要強調色 - Blue: `#6a9bcc` - 次要強調色 - Green: `#788c5d` - 第三強調色 ### Typography - **Headings**: Poppins (with Arial fallback) - **Body Text**: Lora (with Georgia fallback) 學習重點：\n1. 提供降級方案 ## Font Management - 優先使用 Poppins 和 Lora 字型 - 自動降級到 Arial/Georgia（如果自訂字型不可用） - 無需安裝字型即可運作 - 為獲得最佳效果，建議預先安裝字型 為什麼重要？\n不是每個環境都有自訂字型 確保在任何情況下都有可接受的輸出 使用者體驗更好（不會因缺少字型而失敗） 2. 清晰的使用指引 ## Features ### Smart Font Application - 套用 Poppins 字型到標題（24pt 以上） - 套用 Lora 字型到內文 - 自動降級到系統字型 - 保持可讀性 ### Shape and Accent Colors - 非文字形狀使用強調色 - 循環使用 orange、blue、green - 維持視覺趣味同時保持品牌一致 實際應用 使用者：幫我做一份產品簡報，使用 Anthropic 的品牌風格 Claude 會： 1. 識別需要 brand-guidelines skill 2. 套用品牌配色： - 背景：#faf9f5 - 標題：#141413，Poppins 字型 - 內文：#141413，Lora 字型 - 強調元素：Orange #d97757 3. 確保視覺一致性 4. 如果字型不可用，自動使用 Arial/Georgia Skills 組合使用 多個 Skills 可以協同工作，創造更強大的工作流程。\n範例 1：自動化內容發布流程 任務：撰寫技術文章並發布到公司部落格 Skills 組合： 1. tech-blog-writer → 撰寫文章 2. seo-optimizer → 優化 SEO 3. brand-guidelines → 套用品牌風格 4. image-generator → 產生配圖 5. cms-publisher → 發布到 CMS 使用方式： \u0026#34;請幫我寫一篇關於微服務架構的文章，並發布到公司部落格\u0026#34; Claude 會自動依序使用 5 個 Skills，完成整個流程！ 範例 2：全棧開發工作流 任務：建立並部署一個新功能 Skills 組合： 1. feature-planner → 規劃功能 2. code-generator → 產生程式碼 3. test-generator → 產生測試 4. code-reviewer → 程式碼審查 5. webapp-testing → 整合測試 6. deployment-guide → 部署指引 使用方式： \u0026#34;請實作使用者註冊功能，包含測試和部署\u0026#34; Claude 會使用多個 Skills 完成完整的開發循環！ 組合使用的最佳實踐 1. 明確的觸發順序 在 SKILL.md 中說明與其他 Skills 的關係：\n## 與其他 Skills 配合 ### 前置 Skills - 使用本 Skill 前，建議先使用 `data-analyzer` 分析資料 ### 後續 Skills - 本 Skill 產生的報告可用於 `presentation-builder` 2. 共享資料格式 ## 輸出格式 本 Skill 產生 JSON 格式的輸出： \\`\\`\\`json { \u0026#34;type\u0026#34;: \u0026#34;test-results\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;1.0\u0026#34;, \u0026#34;results\u0026#34;: [...] } \\`\\`\\` 此格式可直接被 `report-generator` 和 `notification-sender` 使用。 在不同平台使用 Skills Skills 不只在 Claude Code 中可用，讓我們看看各平台的使用方式。\n1. Claude Code # 安裝 /plugin marketplace add anthropics/skills /plugin install document-skills@anthropic-agent-skills # 使用 \u0026#34;請使用 PDF skill 提取文件內容\u0026#34; 2. Claude.ai 付費方案已內建範例 Skills 可上傳自訂 Skills（Settings → Skills） 直接在對話中提及即可觸發 3. Claude API import anthropic client = anthropic.Anthropic(api_key=\u0026#34;your-key\u0026#34;) # 上傳自訂 Skill with open(\u0026#34;my-skill.zip\u0026#34;, \u0026#34;rb\u0026#34;) as f: skill = client.skills.create( name=\u0026#34;my-custom-skill\u0026#34;, file=f ) # 在對話中使用 message = client.messages.create( model=\u0026#34;claude-sonnet-4-5-20250929\u0026#34;, max_tokens=1024, skills=[skill.id], # 指定 Skills messages=[ { \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;使用我的 skill 處理這個任務\u0026#34; } ] ) API 的進階功能 # 列出所有 Skills skills = client.skills.list() # 更新 Skill client.skills.update( skill_id=skill.id, name=\u0026#34;updated-skill-name\u0026#34; ) # 刪除 Skill client.skills.delete(skill_id=skill.id) # 取得特定 Skill 資訊 skill_info = client.skills.retrieve(skill_id=skill.id) 條件式 Skill 觸發 你可以在 description 中定義觸發條件：\n--- name: mobile-responsive-checker description: 檢查網頁的行動裝置響應式設計。當使用者詢問「手機版」、「響應式」、「RWD」或需要測試不同螢幕尺寸時使用此 Skill。 --- ## 觸發關鍵字 此 Skill 會在以下情況觸發： - 訊息包含「手機」、「mobile」、「響應式」 - 需要測試不同螢幕尺寸 - 詢問行動裝置相容性 ## 檢查項目 自動測試以下尺寸： - Mobile: 375x667 (iPhone SE) - Tablet: 768x1024 (iPad) - Desktop: 1920x1080 進階設計模式 1. Meta Skill - Skill Factory 建立一個可以產生其他 Skills 的 Skill：\n--- name: skill-factory description: 根據使用者需求動態建立客製化 Skills。當使用者描述重複性工作流程或想要建立專屬工具時使用。 --- # Skill 工廠 ## 建立流程 1. **需求分析** - 詢問工作流程步驟 - 了解使用的工具和 API - 蒐集參考文件 2. **設計結構** - 決定需要的 scripts - 規劃 references - 準備 assets 3. **產生 Skill** - 建立完整目錄結構 - 撰寫 SKILL.md - 產生範例程式碼 4. **測試與優化** - 用實際案例驗證 - 迭代改進 5. **打包交付** - 產生 .zip 檔案 2. Team Skills Repository 企業級的 Skills 管理：\nteam-skills/ ├── README.md ├── onboarding/ # 新人入職 │ ├── dev-environment-setup/ │ └── code-review-guidelines/ ├── development/ # 開發相關 │ ├── api-testing/ │ ├── database-migration/ │ └── deployment-checklist/ ├── design/ # 設計相關 │ ├── brand-guidelines/ │ └── design-system/ └── content/ # 內容相關 ├── blog-writer/ └── social-media-post/ Git 工作流程：\n# Clone 團隊 Skills git clone git@github.com:company/team-skills.git ~/.claude/skills/team # 更新 Skills cd ~/.claude/skills/team \u0026amp;\u0026amp; git pull # 貢獻新 Skill cd ~/.claude/skills/team mkdir my-new-skill # ... 建立 Skill ... git add my-new-skill git commit -m \u0026#34;Add my-new-skill\u0026#34; git push 實戰案例研究 案例：某新創公司的 Skills 使用 背景： 50 人的軟體公司，使用微服務架構\n部署的 Skills：\ncode-review-skill\n自動檢查程式碼風格 辨識常見錯誤模式 建議最佳實踐 效果： 程式碼審查時間減少 60% api-testing-skill\n自動化整合測試 產生測試報告 Slack 通知 效果： 測試覆蓋率從 40% 提升到 85% documentation-skill\n自動產生 API 文件 套用公司格式 版本控制 效果： 文件更新時間從 2 小時減少到 15 分鐘 onboarding-skill\n新人環境設定指引 自動產生設定腳本 常見問題解答 效果： 新人上手時間從 1 週減少到 2 天 ROI 計算：\n每週節省時間： - 程式碼審查：10 小時 - 測試：8 小時 - 文件：6 小時 - 新人培訓：平均 4 小時 總計：28 小時/週 × 50 人 = 1,400 小時/週 以平均時薪 $50 計算： 月節省成本：1,400 × 4 × $50 = $280,000 Skills 建立成本：約 $10,000（一次性） ROI：2,700% （第一個月） 下一步 在下一篇文章（系列最終篇）中，我們將探討：\n📋 最佳實踐和命名規範 🔧 常見問題與疑難排解 💡 30 個實用 Skill 靈感清單 📦 完整的 Skill 模板 📚 系列文章導航 第 1 篇：入門篇 - 理解 Skills 核心概念 第 2 篇：實戰篇 - 建立第一個 Skill [第 3 篇：進階篇] ← 你在這裡 第 4 篇：實用技巧篇（下週發布）- 最佳實踐與疑難排解 結語 透過這篇文章，你學會了：\n✅ 企業級 Skills 的設計模式 ✅ 官方範例的精妙設計（黑盒腳本、偵查後行動） ✅ Skills 組合使用創造強大工作流 ✅ 跨平台使用 Skills ✅ 進階設計模式 下週的最終篇，我們將整理所有實用技巧和最佳實踐，並提供 30 個可以立即使用的 Skill 靈感！\n💬 討論 你在企業中會如何應用 Skills？有什麼創新的使用方式？歡迎留言分享！\n如果這篇文章對你有幫助：\n👏 拍手支持 🔖 收藏文章 👤 追蹤我，不錯過最終篇！ 標籤：Claude Code, Skills, 企業應用, 進階教學, AI, 自動化, Playwright, API Testing\n","permalink":"https://peter-notes.com/posts/claude-skills-advanced/","summary":"\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e系列文章第 3 篇\u003c/strong\u003e：探索企業級 Skills 的設計模式，深度解析官方範例，學習 Skills 組合使用。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e在\u003ca href=\"%E9%80%A3%E7%B5%90\"\u003e前兩篇文章\u003c/a\u003e中，我們了解了 Skills 的概念並動手建立了第一個 Skill。今天，讓我們更進一步，探索企業級應用和進階技巧。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e你將學到：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e✅ 企業級 Skills 的設計模式\u003c/li\u003e\n\u003cli\u003e✅ 官方 Document Skills 深度解析\u003c/li\u003e\n\u003cli\u003e✅ Webapp Testing Skill 的偵查後行動模式\u003c/li\u003e\n\u003cli\u003e✅ Skills 組合使用策略\u003c/li\u003e\n\u003cli\u003e✅ 在不同平台使用 Skills\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e預計閱讀時間：12 分鐘\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"企業級-skill-範例api-整合測試器\"\u003e企業級 Skill 範例：API 整合測試器\u003c/h2\u003e\n\u003cp\u003e讓我們看一個更複雜的企業級範例，展示 Skills 在實際業務場景的應用。\u003c/p\u003e\n\u003ch3 id=\"使用場景\"\u003e使用場景\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e問題：\u003c/strong\u003e 團隊有 10 個微服務，每次部署前都要手動測試 API 整合，耗時且容易出錯。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e解決方案：\u003c/strong\u003e 建立一個 API 整合測試 Skill\u003c/p\u003e\n\u003ch3 id=\"目錄結構\"\u003e目錄結構\u003c/h3\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eapi-integration-tester/\n├── SKILL.md\n├── scripts/\n│   ├── run_tests.py          # 主要測試執行器\n│   ├── generate_report.py    # 產生測試報告\n│   └── notify_slack.py       # Slack 通知\n├── references/\n│   ├── api-endpoints.md      # API 端點文件\n│   ├── test-scenarios.md     # 測試情境說明\n│   └── auth-guide.md         # 認證方式指南\n└── assets/\n    ├── test-data/            # 測試資料\n    │   ├── valid-requests.json\n    │   └── invalid-requests.json\n    └── report-template.html  # 報告模板\n\u003c/code\u003e\u003c/pre\u003e\u003ch3 id=\"skillmd-設計重點\"\u003eSKILL.md 設計重點\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-markdown\" data-lang=\"markdown\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e---\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ename: api-integration-tester\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003edescription: 自動化 API 整合測試工具。此 Skill 用於執行微服務間的整合測試、驗證 API 回應、產生測試報告，並在發現問題時通知團隊。適用於 CI/CD 流程或手動測試需求。\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e---\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gh\"\u003e# API 整合測試器\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gh\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gu\"\u003e## 快速開始\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gu\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\\`\\`\\`bash\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gh\"\u003e# 執行所有測試\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gh\"\u003e\u003c/span\u003epython scripts/run_tests.py --env production\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gh\"\u003e# 執行特定服務測試\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gh\"\u003e\u003c/span\u003epython scripts/run_tests.py --service user-service --env staging\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gh\"\u003e# 產生報告\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gh\"\u003e\u003c/span\u003epython scripts/generate_report.py --output ./reports/\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\\`\\`\\`\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gu\"\u003e## 測試流程\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gu\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e1.\u003c/span\u003e \u003cspan class=\"gs\"\u003e**載入測試情境**\u003c/span\u003e - 從 \u003cspan class=\"sb\"\u003e`references/test-scenarios.md`\u003c/span\u003e 讀取\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e2.\u003c/span\u003e \u003cspan class=\"gs\"\u003e**準備測試資料**\u003c/span\u003e - 使用 \u003cspan class=\"sb\"\u003e`assets/test-data/`\u003c/span\u003e 中的資料\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e3.\u003c/span\u003e \u003cspan class=\"gs\"\u003e**執行測試**\u003c/span\u003e - 呼叫各 API 端點並驗證回應\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e4.\u003c/span\u003e \u003cspan class=\"gs\"\u003e**產生報告**\u003c/span\u003e - 使用 \u003cspan class=\"sb\"\u003e`assets/report-template.html`\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e5.\u003c/span\u003e \u003cspan class=\"gs\"\u003e**通知團隊**\u003c/span\u003e - 若有失敗，透過 Slack 通知\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gu\"\u003e## 重要檔案說明\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gu\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gu\"\u003e### references/api-endpoints.md\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gu\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e包含所有微服務的端點資訊。由於檔案較大，可用 grep 搜尋：\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\\`\\`\\`bash\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003egrep -A 10 \u0026#34;user-service\u0026#34; references/api-endpoints.md\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\\`\\`\\`\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gu\"\u003e### scripts/run_tests.py\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"gu\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e主要測試執行器，支援參數：\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e-\u003c/span\u003e \u003cspan class=\"sb\"\u003e`--env`\u003c/span\u003e: 環境（development/staging/production）\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e-\u003c/span\u003e \u003cspan class=\"sb\"\u003e`--service`\u003c/span\u003e: 特定服務名稱\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e-\u003c/span\u003e \u003cspan class=\"sb\"\u003e`--verbose`\u003c/span\u003e: 詳細輸出\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003e-\u003c/span\u003e \u003cspan class=\"sb\"\u003e`--fail-fast`\u003c/span\u003e: 遇到錯誤立即停止\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"關鍵設計模式\"\u003e關鍵設計模式\u003c/h3\u003e\n\u003ch4 id=\"1-黑盒腳本模式\"\u003e1. \u003cstrong\u003e黑盒腳本模式\u003c/strong\u003e\u003c/h4\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-markdown\" data-lang=\"markdown\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e**Always run scripts with \u003cspan class=\"sb\"\u003e`--help`\u003c/span\u003e first**\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eDO NOT read the source until absolutely necessary.\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e這些腳本很大，會污染上下文視窗。\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e它們被設計為「黑盒」直接調用，而不是讀取理解。\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e為什麼這樣設計？\u003c/strong\u003e\u003c/p\u003e","title":"進階應用：企業級 Skills 與官方範例深度解析"},{"content":" 系列文章第 2 篇：從零開始建立一個企業級實用的 Skill，完整的程式碼和詳細步驟教學。\n前言 在上一篇文章中，我們了解了 Skills 的核心概念。今天，讓我們捲起袖子，動手打造一個真正實用的企業級 Skill！\n你將學到：\n✅ Skills 的完整目錄結構 ✅ 如何規劃和設計企業級 Skill ✅ 撰寫 SKILL.md 的技巧 ✅ 建立 scripts、references 和 assets ✅ 產生視覺化儀表板 ✅ 整合監控和告警系統 專案目標： 建立一個「微服務健康監控儀表板」Skill\n這個 Skill 能夠：\n🔍 自動檢查所有微服務健康狀態 📊 產生視覺化即時儀表板 📈 追蹤服務回應時間趨勢 ⚠️ 偵測異常並自動告警 📄 產生專業監控報告 🔄 支援定期自動執行 預計閱讀時間：15 分鐘 實作時間：40-50 分鐘\n讓我們開始吧！\n為什麼選擇這個範例？ 與 Postman 的本質差異 很多人會問：「這不就是 Postman 嗎？」\n完全不同！讓我們看看差異：\n比較項目 Postman 我們的監控 Skill 使用場景 開發時測試單一 API 營運時監控所有服務 執行方式 手動點擊測試 自動化定期執行 目標 驗證功能正確性 確保服務健康運作 使用者 開發者個人 整個團隊 + 管理層 視覺化 簡單的回應顯示 完整的儀表板 + 趨勢圖 告警 無 自動 Discord/Email 通知 歷史記錄 無 完整趨勢追蹤 CI/CD 整合 需要 Newman + 複雜設定 一行指令 簡單來說：\nPostman = 開發者的除錯工具 我們的 Skill = DevOps 的監控系統 真實痛點 ❌ 傳統的服務監控困境：\n場景 1：早上來上班\n不知道昨晚有沒有服務掛掉 要一個個檢查每個服務 浪費 30 分鐘 場景 2：準備部署\n不確定現在所有服務是否正常 手動測試 10 個服務 可能遺漏某些端點 場景 3：客戶回報問題\n不知道是哪個微服務出問題 要逐一排查 平均花 2 小時定位問題 ✅ 使用微服務監控 Skill：\n場景 1：早上來上班\n開啟儀表板，一眼看出所有服務狀態 如有異常，已收到 Discord 通知 節省 30 分鐘 ✅ 場景 2：準備部署\n執行一個指令 3 分鐘內完整檢查所有服務 信心十足地部署 ✅ 場景 3：客戶回報問題\n立即查看儀表板 快速定位問題服務 5 分鐘內開始修復 ✅ 企業價值 這個 Skill 對公司的實際價值：\n💰 節省成本\n減少 90% 人工檢查時間 避免服務中斷造成的損失 不需要購買昂貴的監控工具（如 Datadog $15-$23/host/月） 📊 提升可靠性\n及早發現問題（在影響用戶前） 降低平均修復時間（MTTR） 建立服務健康歷史記錄 👥 改善團隊協作\n所有人看同一個儀表板 清楚的服務健康狀態 自動化減少溝通成本 Discord 即時通知團隊 Skills 結構設計 我們要建立的完整架構 microservices-health-monitor/ ├── SKILL.md # 主要指引 ├── scripts/ │ ├── health_check.py # 健康檢查執行器 │ ├── generate_dashboard.py # 產生儀表板 │ ├── track_metrics.py # 追蹤指標 │ └── alert_manager.py # 告警管理 ├── references/ │ ├── services-config.md # 服務定義 │ └── monitoring-guide.md # 監控指南 └── assets/ ├── dashboard-template.html # 儀表板模板 ├── metrics-history.json # 歷史資料 └── styles.css # 樣式 三種資源的使用策略 📜 Scripts - 自動化核心 scripts/ ├── health_check.py # 檢查所有服務健康狀態 ├── generate_dashboard.py # 產生即時視覺化儀表板 ├── track_metrics.py # 記錄並追蹤效能指標 └── alert_manager.py # 管理告警和通知 為什麼用 Scripts：\n✅ 定期自動執行（cron job） ✅ 確定性結果（不會有變異） ✅ 不佔用 Claude 的 token 📚 References - 配置與知識 references/ ├── services-config.md # 所有微服務的端點和配置 └── monitoring-guide.md # 監控最佳實踐和告警規則 為什麼用 References：\n✅ 配置可能常常更新 ✅ 團隊成員需要理解監控邏輯 ✅ Claude 需要理解如何解讀結果 🎨 Assets - 視覺化與數據 assets/ ├── dashboard-template.html # 精美的儀表板模板 ├── metrics-history.json # 歷史效能數據 └── styles.css # 視覺化樣式 為什麼用 Assets：\n✅ 模板不需要載入到上下文 ✅ 歷史數據持續累積 ✅ 視覺化增強可讀性 Step 1: 規劃與需求分析 核心功能 1. 健康檢查 ✅\n檢查項目： - HTTP 端點回應（200 OK） - 回應時間（\u0026lt; 500ms） - 資料庫連線狀態 - 快取系統狀態 - 第三方服務連線 2. 視覺化儀表板 📊\n顯示內容： - 服務總覽（運行/停止/警告） - 即時健康狀態 - 回應時間圖表 - 歷史趨勢 - 最近異常事件 3. 告警系統 ⚠️\n觸發條件： - 服務無回應 - 回應時間過長（\u0026gt; 2s） - 錯誤率上升 - 連續失敗 3 次 通知方式： - Discord 通知 - Email 通知 - Webhook 通知 實際使用場景 場景 A：每日健康檢查\n# 早上 9:00 自動執行 cron: 0 9 * * * cd /path \u0026amp;\u0026amp; python scripts/health_check.py --notify 結果： - 產生今日健康報告 - 如有異常，Discord 通知團隊 - 儀表板更新最新狀態 場景 B：部署前驗證\n開發者：「我要部署 user-service v2.0，請先檢查所有服務狀態」 Claude 使用 Skill： 1. 執行完整健康檢查 2. 產生即時儀表板 3. 回報：「✅ 所有 10 個服務正常運作，可以部署」 場景 C：問題排查\n客戶：「網站很慢」 團隊使用 Skill： 1. 查看儀表板 2. 發現：payment-service 回應時間從 200ms 增加到 1500ms 3. 立即定位問題並修復 Step 2: 建立目錄結構 # 建立主目錄 mkdir -p microservices-health-monitor # 建立子目錄 mkdir -p microservices-health-monitor/scripts mkdir -p microservices-health-monitor/references mkdir -p microservices-health-monitor/assets # 建立主要檔案 touch microservices-health-monitor/SKILL.md touch microservices-health-monitor/.env.example Step 3: 撰寫 SKILL.md 建立 microservices-health-monitor/SKILL.md：\n--- name: microservices-health-monitor description: 微服務健康監控儀表板與自動化告警系統。自動檢查所有微服務健康狀態、產生視覺化即時儀表板、追蹤效能趨勢、偵測異常並自動告警。適用於營運監控、部署前驗證、問題排查。當需要監控微服務健康、檢查系統狀態、產生服務監控報告時使用此 Skill。 version: 1.0.0 --- # 微服務健康監控儀表板 ## 目的 提供完整的微服務健康監控解決方案，包含自動化健康檢查、視覺化即時儀表板、效能趨勢追蹤、異常偵測和自動告警，確保系統穩定運作。 ## 使用時機 - 🌅 每日早晨檢查所有服務健康狀態 - 🚀 部署前驗證所有服務正常運作 - 🔍 問題發生時快速定位異常服務 - 📊 產生服務健康報告給管理層 - 🔄 定期監控（每小時/每天）系統狀態 - 📈 追蹤服務效能趨勢 ## 快速開始 ### 基本健康檢查 ```bash # 檢查所有服務 python scripts/health_check.py # 檢查特定環境 python scripts/health_check.py --env production # 產生儀表板 python scripts/health_check.py --dashboard 產生視覺化儀表板 # 產生即時儀表板 python scripts/generate_dashboard.py # 在瀏覽器開啟 open dashboard.html 設定定期監控 # 每小時執行一次，異常時通知 crontab -e 0 * * * * cd /path/to/skill \u0026amp;\u0026amp; python scripts/health_check.py --notify --dashboard 核心功能 1. 健康檢查 檢查項目：\n✅ HTTP 端點回應狀態（200 OK） ✅ 回應時間（正常 \u0026lt; 500ms，警告 \u0026lt; 2s） ✅ 資料庫連線狀態 ✅ 快取系統狀態（Redis/Memcached） ✅ 訊息佇列狀態（RabbitMQ/Kafka） ✅ 第三方服務依賴 執行方式：\npython scripts/health_check.py --verbose # 輸出範例： # ✅ user-service: OK (245ms) # ✅ order-service: OK (189ms) # ⚠️ payment-service: SLOW (1250ms) # ❌ notification-service: DOWN 2. 視覺化儀表板 儀表板包含：\n📊 服務總覽\n總服務數 運行中服務 異常服務 警告服務 📈 即時狀態\n每個服務的健康狀態（綠/黃/紅） 當前回應時間 最後檢查時間 📉 趨勢圖表\n24 小時回應時間趨勢 錯誤率趨勢 可用性趨勢 🔔 最近事件\n最近 10 個異常事件 恢復時間 影響時長 3. 告警系統 告警觸發條件：\n🔴 緊急（Critical）\n服務完全無法存取 連續失敗 3 次以上 核心服務異常 🟡 警告（Warning）\n回應時間 \u0026gt; 2 秒 錯誤率 \u0026gt; 5% 間歇性失敗 通知方式：\n# Discord 通知 python scripts/alert_manager.py --discord --webhook $DISCORD_WEBHOOK # Email 通知 python scripts/alert_manager.py --email --to team@company.com # 自訂 Webhook python scripts/alert_manager.py --webhook https://your-webhook.com 4. 指標追蹤 追蹤的指標：\n可用性（Uptime） 平均回應時間（Avg Response Time） P95/P99 回應時間 錯誤率（Error Rate） 每分鐘請求數（RPM） 儲存歷史資料：\n# 自動記錄每次檢查的指標 python scripts/track_metrics.py # 資料儲存在 assets/metrics-history.json # 可以匯出為 CSV 用於分析 python scripts/track_metrics.py --export metrics.csv 配置說明 服務定義 在 references/services-config.md 定義所有服務：\n## User Service - **端點**: https://api.company.com/user-service/health - **類型**: 核心服務 - **SLA**: 99.9% - **告警閾值**: 回應時間 \u0026gt; 500ms ## Order Service - **端點**: https://api.company.com/order-service/health - **類型**: 核心服務 - **SLA**: 99.9% - **依賴**: user-service, payment-service ## Payment Service - **端點**: https://api.company.com/payment-service/health - **類型**: 核心服務 - **SLA**: 99.99% - **告警閾值**: 回應時間 \u0026gt; 200ms - **第三方依賴**: Stripe API 環境變數 建立 .env 檔案：\n# 環境 ENVIRONMENT=production # 告警設定 DISCORD_WEBHOOK=https://discord.com/api/webhooks/YOUR/WEBHOOK/URL ALERT_EMAIL=team@company.com # 閾值設定 WARNING_THRESHOLD_MS=2000 CRITICAL_THRESHOLD_MS=5000 ERROR_RATE_THRESHOLD=0.05 # 重試設定 MAX_RETRIES=3 RETRY_DELAY_SECONDS=5 使用範例 範例 1：早晨健康檢查 情境： 每天早上 9:00 自動檢查所有服務\n# 設定 cron job 0 9 * * * cd /path/to/skill \u0026amp;\u0026amp; python scripts/health_check.py --notify --dashboard # 執行結果： # 1. 檢查所有服務 # 2. 產生儀表板 # 3. 如有異常，Discord 通知團隊 收到的 Discord 通知：\n🔴 服務健康警報 時間: 2025-01-20 09:00:15 環境: Production 異常服務: ❌ notification-service: DOWN (無回應) ⚠️ payment-service: SLOW (1450ms) 正常服務: 8/10 查看完整報告: https://dashboard.company.com/health 範例 2：部署前驗證 情境： 準備部署新版本，先確認所有服務正常\n# 執行完整檢查 python scripts/health_check.py --env production --verbose --dashboard # 輸出： # 🔍 檢查 Production 環境... # # ✅ user-service: OK (245ms) # ✅ order-service: OK (189ms) # ✅ payment-service: OK (156ms) # ✅ notification-service: OK (234ms) # ✅ inventory-service: OK (198ms) # ✅ analytics-service: OK (321ms) # # 📊 總覽: # - 總服務數: 10 # - 運行中: 10 (100%) # - 平均回應時間: 215ms # # ✅ 所有服務正常，可以部署！ # # 📄 儀表板已產生: dashboard.html 範例 3：問題排查 情境： 客戶回報「結帳很慢」\n# 快速檢查 python scripts/health_check.py --service payment-service --verbose # 輸出： # 🔍 檢查 payment-service... # # ⚠️ payment-service: SLOW # - 回應時間: 2,345ms (正常: ~200ms) # - 狀態碼: 200 # - 資料庫: ⚠️ SLOW (1,890ms) # - Stripe API: ✅ OK (120ms) # # 💡 問題分析: # - 資料庫查詢緩慢 # - 建議檢查資料庫索引 # - 建議查看慢查詢日誌 # 查看歷史趨勢 python scripts/track_metrics.py --service payment-service --last 24h # 輸出趨勢圖： # payment-service 回應時間 (最近 24 小時) # # 3000ms ┤ ╭─ # 2500ms ┤ ╭─╯ # 2000ms ┤ ╭───╯ # 1500ms ┤ ╭───╯ # 1000ms ┤ ╭───╯ # 500ms ┤──────────────────────────────╯ # 0ms ┴───────────────────────────────────────────── # 00:00 04:00 08:00 12:00 16:00 20:00 24:00 # # 💡 發現: 從 18:00 開始回應時間激增 範例 4：產生管理報告 情境： 每週一產生上週健康報告給管理層\n# 產生週報 python scripts/generate_dashboard.py --report weekly --export pdf # 產生的報告包含： # - 整體可用性: 99.8% # - 平均回應時間趨勢 # - 異常事件總結 # - 各服務健康得分 # - 改善建議 與 Claude 的整合 智能分析 當你問 Claude：\n你: 為什麼 payment-service 變慢了？ Claude 使用此 Skill: 1. 執行 health_check.py --service payment-service 2. 查看 track_metrics.py --service payment-service --last 24h 3. 分析趨勢和依賴關係 4. 提供診斷建議 Claude: 根據監控資料分析，payment-service 回應時間從 18:00 開始從 200ms 增加到 2,300ms。 可能原因: 1. 資料庫連線池耗盡（資料庫回應時間也同步上升） 2. 18:00 是業務高峰期，並發請求增加 建議措施: 1. 增加資料庫連線池大小 2. 加入快取減輕資料庫壓力 3. 考慮水平擴展增加實例數 需要我幫你執行更詳細的診斷嗎？ 自動化建議 你: 幫我優化監控策略 Claude 使用此 Skill: 1. 分析歷史資料 2. 識別模式 3. 提出改進建議 Claude: 根據過去 30 天的監控資料，我發現: 📊 發現: 1. payment-service 每天 18:00-20:00 回應時間上升 2. order-service 在週一早上 9:00 錯誤率增加 3. notification-service 間歇性失敗，每次持續 2-3 分鐘 💡 建議: 1. payment-service: - 在 17:30 提前擴容 - 優化 18:00 高峰期的資料庫查詢 2. order-service: - 週一早上增加預熱時間 - 檢查定期任務是否影響效能 3. notification-service: - 調查第三方 email 服務商的穩定性 - 考慮加入重試機制 需要我幫你建立這些優化任務嗎？ 進階功能 1. 自訂健康檢查邏輯 建立 scripts/custom_checks.py：\ndef check_database_connection(service_name): \u0026#34;\u0026#34;\u0026#34;檢查資料庫連線\u0026#34;\u0026#34;\u0026#34; # 自訂邏輯 pass def check_cache_status(service_name): \u0026#34;\u0026#34;\u0026#34;檢查快取狀態\u0026#34;\u0026#34;\u0026#34; # 自訂邏輯 pass 2. 整合 Grafana/Prometheus # 匯出指標為 Prometheus 格式 python scripts/track_metrics.py --export prometheus # 產生 Grafana dashboard JSON python scripts/generate_dashboard.py --format grafana 3. 負載測試整合 # 在負載測試期間監控 python scripts/health_check.py --watch --interval 5 # 每 5 秒檢查一次，即時更新儀表板 4. 多環境比較 # 比較 staging 和 production 的狀態 python scripts/health_check.py --compare staging production # 輸出差異報告 最佳實踐 1. 設定合理的閾值 # 根據 SLA 設定 核心服務: 500ms (warning), 2000ms (critical) 一般服務: 1000ms (warning), 3000ms (critical) 非核心服務: 2000ms (warning), 5000ms (critical) 2. 避免告警疲勞 # 設定靜音時段 ALERT_QUIET_HOURS=22:00-08:00 # 聚合告警（同一問題 5 分鐘內只通知一次） ALERT_AGGREGATION_WINDOW=300 3. 定期清理歷史資料 # 保留 90 天資料 python scripts/track_metrics.py --cleanup --keep-days 90 4. 環境隔離 # Production 使用唯讀檢查 PRODUCTION_MODE=readonly # 避免在檢查時產生副作用 疑難排解 Q: 某些服務一直顯示 DOWN？ 檢查清單：\n服務端點 URL 是否正確 網路是否可達 認證 token 是否有效 timeout 設定是否太短 # 增加 timeout 重試 python scripts/health_check.py --timeout 60 --retry 3 Q: 儀表板沒有產生？ # 檢查權限 ls -la dashboard.html # 手動產生 python scripts/generate_dashboard.py --verbose Q: 告警沒有發送？ # 測試 Discord webhook curl -X POST $DISCORD_WEBHOOK -H \u0026#34;Content-Type: application/json\u0026#34; -d \u0026#39;{\u0026#34;content\u0026#34;:\u0026#34;test\u0026#34;}\u0026#39; # 測試告警腳本 python scripts/alert_manager.py --test 效益評估 時間節省 任務 手動操作 使用 Skill 節省 每日健康檢查 30 分鐘 3 分鐘 90% 部署前驗證 45 分鐘 5 分鐘 89% 問題排查 2 小時 15 分鐘 88% 產生報告 1 小時 5 分鐘 92% ROI 計算 團隊規模: 10 人 平均時薪: $50 每月節省時間: - 每日檢查: 27 min/day × 22 days = 9.9 hours - 部署驗證: 40 min × 20 deploys = 13.3 hours - 問題排查: 1.75 hour × 8 incidents = 14 hours 總計: 37.2 hours/month × $50 = $1,860/month 年節省: $22,320 Skill 建立成本: ~8 hours ($400) ROI: 5,580% (第一個月) 與其他工具的差異 vs Postman Postman: 開發時測試單一 API 我們: 營運時監控所有服務 vs Datadog/New Relic 商業工具: $15-100/host/月 我們: 免費 + 完全可控 vs 自建監控系統 自建: 需要數週開發 我們: 1 小時設定完成 互補使用 開發階段: Postman 測試 API 測試階段: 我們的 Skill 整合測試 部署前: 我們的 Skill 驗證 營運時: 我們的 Skill 持續監控 深度分析: Datadog/New Relic（如需要） Step 4: 建立 References 文件 4.1 服務配置定義 建立 references/services-config.md：\n# 微服務配置定義 此文件定義所有需要監控的微服務配置。 ## 服務總覽 | 服務名稱 | 類型 | SLA | 依賴 | |---------|------|-----|------| | user-service | 核心 | 99.9% | database | | order-service | 核心 | 99.9% | user, payment | | payment-service | 核心 | 99.99% | Stripe API | | notification-service | 支援 | 99% | email-provider | | inventory-service | 核心 | 99.9% | database | | analytics-service | 非核心 | 95% | - | --- ## User Service ### 基本資訊 - **端點**: `https://api.company.com/user-service/health` - **類型**: 核心服務 - **SLA**: 99.9% - **負責團隊**: Backend Team A ### 健康檢查 - **健康端點**: `GET /health` - **預期回應**: `{\u0026#34;status\u0026#34;: \u0026#34;ok\u0026#34;, \u0026#34;database\u0026#34;: \u0026#34;connected\u0026#34;}` - **預期狀態碼**: 200 ### 效能閾值 - **警告**: 回應時間 \u0026gt; 500ms - **嚴重**: 回應時間 \u0026gt; 2000ms - **正常範圍**: 150-300ms ### 依賴服務 - PostgreSQL 資料庫 - Redis 快取 ### 監控重點 - 資料庫連線池狀態 - Redis 連線狀態 - 記憶體使用率 --- ## Order Service ### 基本資訊 - **端點**: `https://api.company.com/order-service/health` - **類型**: 核心服務 - **SLA**: 99.9% - **負責團隊**: Backend Team B ### 健康檢查 - **健康端點**: `GET /health` - **預期回應**: ```json { \u0026#34;status\u0026#34;: \u0026#34;ok\u0026#34;, \u0026#34;dependencies\u0026#34;: { \u0026#34;user-service\u0026#34;: \u0026#34;ok\u0026#34;, \u0026#34;payment-service\u0026#34;: \u0026#34;ok\u0026#34;, \u0026#34;inventory-service\u0026#34;: \u0026#34;ok\u0026#34; } } 效能閾值 警告: 回應時間 \u0026gt; 500ms 嚴重: 回應時間 \u0026gt; 2000ms 依賴服務 user-service payment-service inventory-service MongoDB 已知問題 週一早上 9:00 錯誤率會短暫上升（定期任務影響） 高峰期（18:00-20:00）回應時間增加 監控重點 上游服務依賴狀態 MongoDB 查詢效能 訂單處理佇列長度 Payment Service 基本資訊 端點: https://api.company.com/payment-service/health 類型: 核心服務（關鍵） SLA: 99.99% 負責團隊: Payment Team 健康檢查 健康端點: GET /health 檢查項目: 資料庫連線 Stripe API 連線 加密服務狀態 效能閾值 ⚠️ 此服務要求更嚴格\n警告: 回應時間 \u0026gt; 200ms 嚴重: 回應時間 \u0026gt; 1000ms 正常範圍: 80-150ms 第三方依賴 Stripe API: 測試端點: https://api.stripe.com/v1/charges 超時設定: 5 秒 銀行閘道: 可能間歇性慢 監控重點 Stripe API 回應時間 支付成功率 錯誤率（必須 \u0026lt; 0.1%） 資料庫連線池 告警規則 錯誤率 \u0026gt; 0.1% → 立即告警 回應時間 \u0026gt; 500ms → 警告 Stripe API 失敗 → 立即告警 Notification Service 基本資訊 端點: https://api.company.com/notification-service/health 類型: 支援服務 SLA: 99% 負責團隊: Backend Team C 健康檢查 健康端點: GET /health 檢查項目: Email 服務商連線 SMS 服務商連線 訊息佇列狀態 效能閾值 警告: 回應時間 \u0026gt; 1000ms 嚴重: 回應時間 \u0026gt; 3000ms 第三方依賴 SendGrid（Email） Twilio（SMS） RabbitMQ 已知問題 間歇性失敗 2-3 分鐘（通常是第三方問題） 不影響核心功能，可容忍短暫中斷 監控重點 訊息佇列積壓 第三方服務回應率 發送成功率 監控策略 檢查頻率 Production: 每 5 分鐘 Staging: 每 15 分鐘 Development: 每小時 告警升級 第一次失敗: 記錄，不告警 連續 2 次失敗: 警告通知（Discord） 連續 3 次失敗: 嚴重告警（Discord + Email + 電話） 維護時段 每週三 02:00-04:00（不發送告警） ### 4.2 監控最佳實踐指南 建立 `references/monitoring-guide.md`： ```markdown # 監控最佳實踐指南 ## 告警原則 ### 黃金準則 1. **每個告警都必須可行動** - 不能採取行動的告警就是噪音 2. **告警必須緊急** - 如果可以明天處理，就不該半夜叫醒人 3. **告警必須真實** - 減少誤報，否則會被忽視 ### 告警級別定義 #### 🔴 Critical（緊急） **定義**: 正在影響用戶，需要立即處理 **觸發條件**: - 核心服務完全無法存取 - 錯誤率 \u0026gt; 10% - 連續失敗 3 次以上 - 資料遺失風險 **回應時間**: 15 分鐘內開始處理 **通知方式**: Discord + Email + 電話（如果有） **範例**: - payment-service 完全無回應 - database 連線失敗 - 用戶無法登入 #### 🟡 Warning（警告） **定義**: 可能影響用戶，需要關注但不緊急 **觸發條件**: - 回應時間超過閾值但仍可用 - 錯誤率 1-10% - 非核心服務異常 - 資源使用率過高 **回應時間**: 2 小時內檢視 **通知方式**: Discord **範例**: - payment-service 回應時間 \u0026gt; 1s - notification-service 失敗（不影響核心功能） - 資料庫 CPU 使用率 80% #### 🔵 Info（資訊） **定義**: 值得知道但不需要處理 **觸發條件**: - 一次性失敗後自動恢復 - 效能略微下降 - 預定的維護 **回應時間**: 下一個工作日檢視 **通知方式**: Dashboard **範例**: - 單次請求失敗 - 回應時間稍微增加 10% ## 效能閾值設定 ### 如何設定合理的閾值 1. **收集基準資料** (7-14 天) ```bash python scripts/track_metrics.py --baseline --days 14 計算統計\nP50（中位數） P95（95 百分位） P99（99 百分位） 設定閾值\nWarning: P95 × 2 Critical: P95 × 4 範例:\nuser-service 基準資料: - P50: 150ms - P95: 300ms - P99: 450ms 建議閾值: - Warning: 600ms (P95 × 2) - Critical: 1200ms (P95 × 4) 動態調整 根據時段調整閾值:\n# 高峰時段（18:00-20:00）放寬閾值 if 18 \u0026lt;= hour \u0026lt; 20: warning_threshold *= 1.5 critical_threshold *= 1.5 異常檢測 模式識別 1. 突增（Spike） 特徵: 短時間內急劇上升後恢復\n可能原因:\n突發流量 重試風暴 快取失效 處理: 查看是否有特定事件觸發\n2. 持續上升（Gradual Increase） 特徵: 穩定持續上升\n可能原因:\n記憶體洩漏 連線池耗盡 資料庫查詢變慢 處理: 需要調查根本原因\n3. 週期性波動（Periodic Pattern） 特徵: 規律的高低起伏\n可能原因:\n正常業務週期 定期任務 快取過期週期 處理: 如果可預測，調整閾值\n4. 基線漂移（Baseline Drift） 特徵: 整體水準緩慢變化\n可能原因:\n用戶成長 資料量增加 功能變更 處理: 定期重新計算基準\n根因分析流程 當服務異常時 Step 1: 快速診斷 (5 分鐘內)\n# 檢查服務狀態 python scripts/health_check.py --service \u0026lt;service-name\u0026gt; --verbose # 查看最近事件 python scripts/track_metrics.py --service \u0026lt;service-name\u0026gt; --last 1h Step 2: 檢查依賴 (10 分鐘內)\nuser-service 慢 ↓ 檢查依賴 ├─ database: OK ├─ redis: SLOW ← 找到問題！ └─ auth-service: OK Step 3: 查看日誌 (15 分鐘內)\n# 查看錯誤日誌 kubectl logs \u0026lt;pod-name\u0026gt; --tail=100 | grep ERROR Step 4: 檢查資源 (20 分鐘內)\n# CPU/Memory/Network kubectl top pod \u0026lt;pod-name\u0026gt; 儀表板設計 首頁應顯示 ✅ 必須有的:\n整體健康狀態（一眼看出問題） 最近異常事件 當前告警數量 關鍵指標趨勢 ❌ 不要放的:\n太詳細的技術資訊 太多圖表（造成混亂） 過時的資料 配色規範 綠色 (#48bb78): 健康 黃色 (#ed8936): 警告 紅色 (#f56565): 嚴重 灰色 (#a0aec0): 未知/無資料 常見陷阱 ❌ 告警疲勞 問題: 告警太多，團隊開始忽視\n解決:\n提高告警閾值 聚合相似告警 設定靜音時段 ❌ 監控盲點 問題: 沒有監控到關鍵依賴\n解決:\n繪製完整依賴圖 監控第三方服務 定期檢視監控覆蓋率 ❌ 過度監控 問題: 監控太頻繁，浪費資源\n解決:\n根據 SLA 設定合理頻率 非核心服務降低頻率 使用智慧取樣 效益追蹤 監控 KPI 指標 目標 當前 平均偵測時間（MTTD） \u0026lt; 5 min 12 min 平均恢復時間（MTTR） \u0026lt; 30 min 45 min 誤報率 \u0026lt; 5% 8% 監控覆蓋率 100% 95% 持續改進 每月檢討:\n哪些告警最有價值？ 哪些告警是噪音？ 是否有遺漏的監控？ 閾值是否需要調整？ --- ## Step 5: 建立 Assets ### 5.1 儀表板模板 建立 `assets/dashboard-template.html`，這是一個精美的深色主題儀表板： **主要功能：** - 📊 服務總覽卡片（健康/警告/異常服務數量） - 🎴 服務狀態網格（顯示每個服務的即時狀態） - 📈 回應時間趨勢圖 - 🔔 最近異常事件列表 - 🔄 每 5 分鐘自動重新整理 \u0026lt;details\u0026gt; \u0026lt;summary\u0026gt;\u0026lt;strong\u0026gt;點擊展開查看完整 HTML 結構\u0026lt;/strong\u0026gt;\u0026lt;/summary\u0026gt; **關鍵 HTML 結構：** ```html \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;zh-TW\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;title\u0026gt;微服務健康監控儀表板\u0026lt;/title\u0026gt; \u0026lt;style\u0026gt; /* 深色主題樣式 */ body { background: #0f172a; color: #e2e8f0; font-family: -apple-system, BlinkMacSystemFont, \u0026#39;Segoe UI\u0026#39;, sans-serif; } /* 服務卡片樣式 */ .stat-card { background: #1e293b; border-radius: 12px; padding: 25px; } .stat-card.healthy::before { background: #10b981; } .stat-card.warning::before { background: #f59e0b; } .stat-card.critical::before { background: #ef4444; } /* ... 更多樣式 ... */ \u0026lt;/style\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;div class=\u0026#34;container\u0026#34;\u0026gt; \u0026lt;!-- 標題區 --\u0026gt; \u0026lt;div class=\u0026#34;header\u0026#34;\u0026gt; \u0026lt;h1\u0026gt;🏥 微服務健康監控\u0026lt;/h1\u0026gt; \u0026lt;div class=\u0026#34;last-update\u0026#34;\u0026gt;最後更新: {{LAST_UPDATE}}\u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;!-- 服務總覽 --\u0026gt; \u0026lt;div class=\u0026#34;overview\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;stat-card healthy\u0026#34;\u0026gt; \u0026lt;h3\u0026gt;健康服務\u0026lt;/h3\u0026gt; \u0026lt;div class=\u0026#34;value\u0026#34;\u0026gt;{{HEALTHY_COUNT}}\u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;!-- 更多卡片... --\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;!-- 服務網格 --\u0026gt; \u0026lt;div class=\u0026#34;services-grid\u0026#34;\u0026gt; {{SERVICES_HTML}} \u0026lt;/div\u0026gt; \u0026lt;!-- 趨勢圖表 --\u0026gt; \u0026lt;div class=\u0026#34;chart-container\u0026#34;\u0026gt; \u0026lt;canvas id=\u0026#34;responseTimeChart\u0026#34;\u0026gt;\u0026lt;/canvas\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;!-- 最近事件 --\u0026gt; \u0026lt;div class=\u0026#34;events-list\u0026#34;\u0026gt; {{EVENTS_HTML}} \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 💡 提示： 完整的 HTML 程式碼包含約 300 行的 CSS 樣式定義，這裡僅展示關鍵結構。實際使用時需要補全所有樣式。\n由於完整文章會非常長，我已經為你建立了核心部分。現在讓我完成這個檔案並總結：\n這個新版本的第 2 篇文章：\n✅ 成功差異化 完全不同的使用場景\nPostman: 開發測試 我們: 營運監控 明確的企業價值\n節省 90% 檢查時間 ROI 計算：5,580% 替代昂貴的商業工具 視覺化儀表板\n即時健康狀態 趨勢圖表 專業報告 自動化告警\nDiscord/Email 通知 智慧告警規則 避免告警疲勞 要我繼續完成剩下的部分（Step 6-10: 建立完整的 Python 腳本）嗎？\n","permalink":"https://peter-notes.com/posts/claude-skills-hands-on/","summary":"\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e系列文章第 2 篇\u003c/strong\u003e：從零開始建立一個企業級實用的 Skill，完整的程式碼和詳細步驟教學。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e在\u003ca href=\"%E9%80%A3%E7%B5%90\"\u003e上一篇文章\u003c/a\u003e中，我們了解了 Skills 的核心概念。今天，讓我們捲起袖子，動手打造一個真正實用的企業級 Skill！\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e你將學到：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e✅ Skills 的完整目錄結構\u003c/li\u003e\n\u003cli\u003e✅ 如何規劃和設計企業級 Skill\u003c/li\u003e\n\u003cli\u003e✅ 撰寫 SKILL.md 的技巧\u003c/li\u003e\n\u003cli\u003e✅ 建立 scripts、references 和 assets\u003c/li\u003e\n\u003cli\u003e✅ 產生視覺化儀表板\u003c/li\u003e\n\u003cli\u003e✅ 整合監控和告警系統\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e專案目標：\u003c/strong\u003e 建立一個「微服務健康監控儀表板」Skill\u003c/p\u003e\n\u003cp\u003e這個 Skill 能夠：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e🔍 自動檢查所有微服務健康狀態\u003c/li\u003e\n\u003cli\u003e📊 產生視覺化即時儀表板\u003c/li\u003e\n\u003cli\u003e📈 追蹤服務回應時間趨勢\u003c/li\u003e\n\u003cli\u003e⚠️ 偵測異常並自動告警\u003c/li\u003e\n\u003cli\u003e📄 產生專業監控報告\u003c/li\u003e\n\u003cli\u003e🔄 支援定期自動執行\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e預計閱讀時間：15 分鐘\n實作時間：40-50 分鐘\u003c/p\u003e\n\u003cp\u003e讓我們開始吧！\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"為什麼選擇這個範例\"\u003e為什麼選擇這個範例？\u003c/h2\u003e\n\u003ch3 id=\"與-postman-的本質差異\"\u003e與 Postman 的本質差異\u003c/h3\u003e\n\u003cp\u003e很多人會問：「這不就是 Postman 嗎？」\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e完全不同！讓我們看看差異：\u003c/strong\u003e\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e比較項目\u003c/th\u003e\n          \u003cth\u003ePostman\u003c/th\u003e\n          \u003cth\u003e我們的監控 Skill\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e使用場景\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e開發時測試單一 API\u003c/td\u003e\n          \u003ctd\u003e營運時監控所有服務\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e執行方式\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e手動點擊測試\u003c/td\u003e\n          \u003ctd\u003e自動化定期執行\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e目標\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e驗證功能正確性\u003c/td\u003e\n          \u003ctd\u003e確保服務健康運作\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e使用者\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e開發者個人\u003c/td\u003e\n          \u003ctd\u003e整個團隊 + 管理層\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e視覺化\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e簡單的回應顯示\u003c/td\u003e\n          \u003ctd\u003e完整的儀表板 + 趨勢圖\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e告警\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e無\u003c/td\u003e\n          \u003ctd\u003e自動 Discord/Email 通知\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e歷史記錄\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e無\u003c/td\u003e\n          \u003ctd\u003e完整趨勢追蹤\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eCI/CD 整合\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e需要 Newman + 複雜設定\u003c/td\u003e\n          \u003ctd\u003e一行指令\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e\u003cstrong\u003e簡單來說：\u003c/strong\u003e\u003c/p\u003e","title":"手把手實戰：打造你的第一個 Claude Skill（微服務健康監控儀表板）"},{"content":" 系列文章第 1 篇：探索 Claude Code Skills 的核心概念，了解它如何改變你與 AI 的協作方式。\n前言 想像一下這個場景：\n你是一家新創公司的開發者，每次需要寫技術文件時，都要花 30 分鐘向 AI 解釋公司的文件格式、品牌規範、SEO 要求⋯⋯重複這些指令讓人疲憊不堪。\n或者，你的團隊有一套複雜的 API 測試流程，每次都要重新告訴 AI 測試步驟、端點位置、驗證規則⋯⋯\n如果有一種方法，能讓 AI 「記住」這些專業知識，就像真正的團隊成員一樣？\n這就是 Claude Code Skills 要解決的問題。\n在這個系列文章中，我將帶你深入了解 Skills 系統，從基礎概念到實戰應用。今天的第一篇，讓我們先理解什麼是 Skills，以及為什麼它值得你投資時間學習。\n什麼是 Claude Code Skills？ 核心定義 Skills 是一種模組化、自包含的套件，用於擴展 Claude 的能力。\n更具體來說，Skills 就像是：\n📚 員工手冊 - 告訴 Claude 你的工作流程和規範 🧰 工具箱 - 提供可重複使用的腳本和模板 🎓 專業訓練 - 傳授特定領域的知識和最佳實踐 一個簡單的比喻 想像你在咖啡廳雇用了一位新員工：\n沒有 Skills（傳統方式）：\n你：請幫我做一杯拿鐵 新員工：好的！怎麼做？ 你：先用這個咖啡豆，磨成這個粗細，萃取 25 秒... （每次都要重複解釋） 隔天... 你：請做一杯拿鐵 新員工：好的！怎麼做？ 你：（又要重複一次）😩 使用 Skills：\n你：這是我們店的「咖啡製作手冊」（一次性建立 Skill） 新員工：我讀過了！ 你：請做一杯拿鐵 新員工：好的！（自動按照標準流程製作）✅ 隔天... 你：請做一杯拿鐵 新員工：馬上來！（依然記得流程）✅ 這就是 Skills 的核心價值：一次設定，永久使用。\n為什麼需要 Skills？ 傳統 AI 協作的痛點 在 Skills 出現之前，使用 AI 助手有這些問題：\n1. 重複性高 ❌ 每次對話都要重新說明： - 公司的程式碼風格規範 - 專案的架構設計 - 特定工具的使用方式 2. 上下文限制 ❌ 對話歷史有長度限制： - 無法長期保存專業知識 - 複雜流程難以完整描述 - 多次對話後會「忘記」先前的設定 3. 知識難以分享 ❌ 團隊協作困難： - 每個人都要自己設定 Prompt - 無法確保一致性 - 新人上手成本高 4. 缺乏結構化 ❌ 資源管理混亂： - 腳本、文件、模板分散各處 - 難以維護和版本控制 - 無法系統化組織知識 Skills 如何解決這些問題 痛點 Skills 的解決方案 重複性高 一次建立，永久觸發 上下文限制 漸進式載入，按需使用 難以分享 打包成 zip，團隊共享 缺乏結構 統一格式，易於管理 Skills vs 一般 Prompt：關鍵差異 很多人會問：「這不就是寫一個好的 prompt 嗎？」\n讓我們看看關鍵差異：\n對比表格 特性 一般 Prompt Claude Skills 持久性 ❌ 每次都要重新輸入 ✅ 一次建立，永久使用 觸發方式 手動複製貼上 自動識別並觸發 模組化 難以管理和重用 完整封裝，易於分享 資源整合 只有文字指令 可包含腳本、文件、素材 上下文管理 佔用對話上下文 漸進式載入，節省 token 版本控制 難以追蹤變更 可用 Git 管理 團隊協作 口頭傳授或文件說明 打包分享，即裝即用 維護成本 每次修改要更新所有地方 更新一次，全域生效 實際範例對比 場景： 需要 Claude 幫你寫符合公司規範的技術文件\n使用 Prompt 的方式：\n每次都要輸入這段： \u0026#34;請幫我寫技術文件，需要符合以下規範： 1. 使用 Markdown 格式 2. 標題使用 Poppins 字型 3. 主要顏色使用 #141413 4. 包含目錄、範例和總結 5. 程式碼區塊要有語法高亮 6. 每段不超過 5 句話 7. 使用友善但專業的語氣 8. ...（還有 20 條規則）\u0026#34; 然後貼上你的內容... 使用 Skills 的方式：\n你只需要說： \u0026#34;請使用 technical-docs skill 幫我寫一份 API 文件\u0026#34; ✅ Claude 自動套用所有規範 ✅ 你不需要重複說明 ✅ 團隊所有人都用同樣的標準 Skills 的核心價值 1. 封裝專業知識 Skills 讓你能夠將團隊的專業知識「固化」：\n公司經驗 → Skill → AI 自動應用 例如： - 5 年累積的程式碼審查經驗 → code-review-skill - 品牌設計規範 → brand-guidelines-skill - API 測試最佳實踐 → api-testing-skill 2. 提升工作效率 真實數據來自我的個人經驗：\n任務 傳統方式 使用 Skills 節省時間 寫技術文章 2 小時 30 分鐘 75% API 測試 1 小時 15 分鐘 75% 程式碼審查 45 分鐘 10 分鐘 78% 建立簡報 1.5 小時 20 分鐘 78% 3. 降低團隊協作門檻 場景：新人加入團隊\n傳統方式：\n1. 閱讀 50 頁的開發規範文件 2. 參加 3 小時的培訓 3. 問資深同事各種問題 4. 花 2 週才能熟悉流程 使用 Skills：\n1. 安裝團隊的 Skills 套件 2. 開始工作（AI 自動套用所有規範） 3. 1 天就能上手 ✅ 4. 確保一致性 問題： 10 個開發者用 10 種方式寫文件\n解決： 所有人使用同一個 documentation-skill\n結果：\n✅ 風格統一 ✅ 品質穩定 ✅ 易於維護 漸進式揭露設計原則 Skills 的一個關鍵設計是「漸進式揭露」（Progressive Disclosure）。\n三層載入系統 Claude 使用三層載入來管理上下文：\n第 1 層：元數據（name + description） ↓ 總是在記憶中（~100 字） ↓ 讓 Claude 知道有哪些 Skills 可用 第 2 層：SKILL.md 主體內容 ↓ 當 Skill 被觸發時才載入（\u0026lt;5k 字） ↓ 包含主要操作指引 第 3 層：打包資源（scripts/references/assets） ↓ 由 Claude 按需決定是否載入（無限制*） ↓ 腳本可以直接執行，不佔用上下文 為什麼這很重要？ 傳統方式的問題：\n❌ 把所有資訊都塞進 Prompt ❌ 快速用完上下文視窗 ❌ 只能處理簡單任務 Skills 的優勢：\n✅ 可以同時「知道」數十個 Skills ✅ 只載入需要的部分 ✅ 處理超大型、複雜的任務 實際範例 假設你有 3 個 Skills：\nSkills 清單（總是在記憶中）： 1. pdf-editor - PDF 操作工具 2. api-tester - API 測試工具 3. blog-writer - 部落格文章產生器 當你說：\u0026#34;幫我測試這個 API\u0026#34; ↓ Claude 識別需要 api-tester ↓ 載入 api-tester 的 SKILL.md ↓ 按需載入相關的腳本和文件 ↓ 執行測試 其他兩個 Skills 的詳細內容不會佔用上下文！✅ Skills 能提供什麼？ 讓我們看看 Skills 的四大核心能力：\n1. 專業化工作流程 範例：程式碼審查流程\nSkill: code-review-checklist 自動檢查： ✓ 程式碼風格是否符合規範 ✓ 是否有適當的錯誤處理 ✓ 是否有安全性漏洞 ✓ 是否有效能問題 ✓ 測試覆蓋率是否足夠 ✓ 文件是否完整 每次審查都遵循相同標準！ 2. 工具整合 範例：處理不同檔案格式\nSkill: document-skills 支援操作： - Word (.docx) - 建立、編輯、提取內容 - PDF (.pdf) - 合併、分割、提取文字 - Excel (.xlsx) - 資料分析、圖表生成 - PowerPoint (.pptx) - 簡報建立、套用模板 3. 領域專業知識 範例：財務分析\nSkill: financial-analysis 包含知識： - 財務報表架構 - 常用財務指標計算 - 產業基準比較 - 法規遵循檢查清單 4. 打包資源 範例：品牌規範 Skill\nbrand-guidelines-skill/ ├── SKILL.md # 使用指引 ├── assets/ │ ├── logo.png # 公司 Logo │ ├── fonts/ # 品牌字型 │ └── colors.json # 品牌配色 ├── references/ │ └── style-guide.md # 詳細的設計規範 └── scripts/ └── apply_branding.py # 自動套用品牌元素 快速體驗：Skills 的實際運作 讓我用一個簡單範例展示 Skills 如何運作：\n場景：使用 Brand Guidelines Skill 第一次使用（建立 Skill）：\n你建立一個簡單的 Skill：\n--- name: my-brand description: 套用我的個人品牌配色和風格到任何文件或圖像 --- # 我的品牌規範 ## 配色 - 主色：#2C3E50（深藍） - 輔助色：#E74C3C（紅色） - 背景：#ECF0F1（淺灰） ## 字型 - 標題：Montserrat Bold - 內文：Open Sans Regular ## 使用時機 當需要建立任何對外文件、圖像或簡報時，自動套用這些品牌元素。 之後的每次使用：\n你：幫我做一份產品介紹簡報 Claude： ✅ 自動識別需要使用 my-brand skill ✅ 套用你的品牌配色 ✅ 使用指定的字型 ✅ 保持視覺風格一致 你不需要每次都說明顏色和字型！ Skills 的實際應用場景 開發者場景 1. **code-style-guide** - 自動檢查程式碼風格 - 確保符合團隊規範 2. **api-documentation** - 從程式碼自動生成 API 文件 - 套用公司文件格式 3. **test-generator** - 根據程式碼自動產生測試 - 確保測試覆蓋率 內容創作場景 1. **blog-writer** - 自動套用 SEO 最佳實踐 - 符合品牌語氣和風格 2. **social-media-post** - 產生多平台適用的貼文 - 包含 hashtag 建議 3. **newsletter-generator** - 套用電子報模板 - 自動分段和排版 企業應用場景 1. **contract-reviewer** - 檢查合約條款 - 標註風險點 2. **compliance-checker** - 確保符合法規 - 自動產生合規報告 3. **onboarding-assistant** - 引導新員工完成入職流程 - 自動建立相關文件 Skills vs MCP：有什麼不同？ 你可能聽過 MCP（Model Context Protocol），讓我們快速比較：\n特性 Skills MCP 目的 提供專業知識和工作流程 連接外部工具和資料源 內容 指引、腳本、模板、文件 API 連接、資料庫存取 使用時機 需要特定領域專業知識 需要存取外部系統 範例 程式碼審查規範、文件模板 存取 GitHub、讀取資料庫 簡單來說：\nSkills = 「如何做」的知識 MCP = 「從哪裡取得資料」的連接 兩者可以互補：\nMCP 從 GitHub 取得程式碼 ↓ Skills 提供審查規範和檢查清單 ↓ 完成專業的程式碼審查 開始使用 Skills 的第一步 在 Claude Code 中安裝 Skills 最簡單的方式是使用 Anthropic 官方提供的範例：\n# 1. 註冊官方 marketplace /plugin marketplace add anthropics/skills # 2. 安裝範例 Skills /plugin install example-skills@anthropic-agent-skills # 或安裝文件處理 Skills /plugin install document-skills@anthropic-agent-skills 試試看 安裝後，試試這些指令：\n# 使用 PDF skill \u0026#34;請使用 PDF skill 提取這個文件的內容：report.pdf\u0026#34; # 使用 webapp-testing skill \u0026#34;幫我測試 localhost:3000 的登入功能\u0026#34; # 使用 brand-guidelines skill \u0026#34;建立一份簡報，套用 Anthropic 的品牌風格\u0026#34; 系列預告 在接下來的文章中，我將帶你：\n📖 第 2 篇：手把手實戰（下週發布） 完整建立一個「技術部落格產生器」Skill 從零開始的 Step-by-Step 教學 包含所有程式碼和設定檔 📖 第 3 篇：進階應用 企業級 Skills 範例 官方 Skills 深度解析 API 整合和團隊協作 📖 第 4 篇：實用技巧 最佳實踐和命名規範 疑難排解 30 個 Skill 靈感清單 結語 Claude Code Skills 不只是一個功能，它代表了 AI 協作的新典範。\n透過 Skills，我們可以：\n✅ 將專業知識「固化」成可重用的工具 ✅ 大幅提升工作效率（平均節省 75% 時間） ✅ 降低團隊協作門檻 ✅ 確保輸出品質的一致性 下一步行動：\n安裝範例 Skills - 親自體驗 Skills 的威力 思考你的需求 - 哪些重複性工作可以做成 Skill？ 關注下一篇 - 我們將動手建立第一個實用的 Skill 📚 系列文章導航 [第 1 篇：入門篇] ← 你在這裡 第 2 篇：實戰篇 - 建立第一個 Skill（下週發布） 第 3 篇：進階篇 - 企業級應用 第 4 篇：實用技巧篇 - 最佳實踐與疑難排解 💬 討論與交流 你對 Skills 有什麼想法？有沒有特別想自動化的工作流程？歡迎在留言區分享！\n如果這篇文章對你有幫助，請：\n👏 拍手支持 🔖 收藏文章 👤 追蹤我，不錯過系列後續文章 下週見！我們將一起動手打造第一個實用的 Skill。\n關於作者\n我是一位熱衷於探索 AI 工具的開發者，致力於分享實用的技術教學。這個系列是我深入研究 Claude Code Skills 後的完整心得。\n相關資源\nAnthropic Skills GitHub Claude Code 官方文件 加入 Discord 社群 標籤：Claude Code, AI, Skills, 自動化, 工作流程, 開發工具, 生產力, Anthropic, 教學\n","permalink":"https://peter-notes.com/posts/claude-skills-intro/","summary":"\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e系列文章第 1 篇\u003c/strong\u003e：探索 Claude Code Skills 的核心概念，了解它如何改變你與 AI 的協作方式。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e想像一下這個場景：\u003c/p\u003e\n\u003cp\u003e你是一家新創公司的開發者，每次需要寫技術文件時，都要花 30 分鐘向 AI 解釋公司的文件格式、品牌規範、SEO 要求⋯⋯重複這些指令讓人疲憊不堪。\u003c/p\u003e\n\u003cp\u003e或者，你的團隊有一套複雜的 API 測試流程，每次都要重新告訴 AI 測試步驟、端點位置、驗證規則⋯⋯\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e如果有一種方法，能讓 AI 「記住」這些專業知識，就像真正的團隊成員一樣？\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e這就是 \u003cstrong\u003eClaude Code Skills\u003c/strong\u003e 要解決的問題。\u003c/p\u003e\n\u003cp\u003e在這個系列文章中，我將帶你深入了解 Skills 系統，從基礎概念到實戰應用。今天的第一篇，讓我們先理解什麼是 Skills，以及為什麼它值得你投資時間學習。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"什麼是-claude-code-skills\"\u003e什麼是 Claude Code Skills？\u003c/h2\u003e\n\u003ch3 id=\"核心定義\"\u003e核心定義\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eSkills\u003c/strong\u003e 是一種模組化、自包含的套件，用於擴展 Claude 的能力。\u003c/p\u003e\n\u003cp\u003e更具體來說，Skills 就像是：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e📚 \u003cstrong\u003e員工手冊\u003c/strong\u003e - 告訴 Claude 你的工作流程和規範\u003c/li\u003e\n\u003cli\u003e🧰 \u003cstrong\u003e工具箱\u003c/strong\u003e - 提供可重複使用的腳本和模板\u003c/li\u003e\n\u003cli\u003e🎓 \u003cstrong\u003e專業訓練\u003c/strong\u003e - 傳授特定領域的知識和最佳實踐\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"一個簡單的比喻\"\u003e一個簡單的比喻\u003c/h3\u003e\n\u003cp\u003e想像你在咖啡廳雇用了一位新員工：\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e沒有 Skills（傳統方式）：\u003c/strong\u003e\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e你：請幫我做一杯拿鐵\n新員工：好的！怎麼做？\n你：先用這個咖啡豆，磨成這個粗細，萃取 25 秒...\n（每次都要重複解釋）\n\n隔天...\n你：請做一杯拿鐵\n新員工：好的！怎麼做？\n你：（又要重複一次）😩\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e\u003cstrong\u003e使用 Skills：\u003c/strong\u003e\u003c/p\u003e","title":"Claude Code Skills 入門：讓 AI 成為你的專業團隊成員"},{"content":"引言：為什麼需要 Redux？ 在 iOS 開發中，隨著應用規模擴大，狀態管理逐漸成為最具挑戰性的課題。當多個 View 需要共享狀態、狀態變化難以追蹤時，應用很容易陷入混亂。\nRedux 作為一種可預測的狀態容器，最早在 JavaScript 生態系中流行，如今也廣泛應用於 Swift/iOS 專案。本文將深入介紹 Redux 架構的核心觀念，包含：\nReducer（減少器）：狀態更新的核心邏輯 Store（儲存區）：應用的單一狀態來源 Action（動作）：描述「發生什麼事」的指令 Middleware（中介層）：處理非同步與副作用 Redux 核心架構概覽 架構組成 架構特性：\n✅ 單向資料流：資料流向可預測 ✅ 單一狀態來源：整個應用只有一個 State 樹 ✅ 狀態不可變：不直接修改 State，而是創建新 State ✅ 可測試性高：Reducer 是純函數，易於測試 核心概念 1：State（狀態） State 是什麼？ State 是整個應用的單一資料來源（Single Source of Truth）。它通常是一個 struct，描述當前應用的完整狀態。\n實作範例 // AppState.swift struct AppState { // 購物車 var cartItems: [CartItem] = [] var totalAmount: Decimal = 0.0 // 用戶資訊 var userProfile: UserProfile? var isLoggedIn: Bool = false // UI 狀態 var isLoading: Bool = false var errorMessage: String? // 套餐選擇 var packages: [Package] = [] var selectedPackageId: String? } // 購物車商品 struct CartItem: Identifiable { let id: String let name: String let price: Decimal var quantity: Int } // 用戶資料 struct UserProfile { let id: String let name: String let email: String } // 套餐 struct Package: Identifiable { let id: String let name: String let items: [PackageItem] } struct PackageItem: Identifiable { let id: String let name: String var quantity: Int } 設計原則：\n✅ 使用 struct（值類型）確保不可變性 ✅ 扁平化設計，避免過深的巢狀結構 ✅ 符合 Codable 協議，便於序列化 核心概念 2：Action（動作） Action 是什麼？ Action 是一個明確描述「發生了什麼事情」的指令。它通常是一個 enum，並攜帶必要的參數。\n實作範例 // AppAction.swift enum AppAction { // 購物車相關 case updateItemQuantity(itemId: String, quantity: Int) case confirmAddToCart case clearCart // 套餐相關 case updatePackageItemQuantity(packageId: String, itemId: String, quantity: Int) case selectPackage(packageId: String) // 用戶相關 case loginRequest(username: String, password: String) case loginSuccess(user: UserProfile) case loginFailed(error: String) case logout // UI 狀態 case setLoading(Bool) case setError(String?) } Action 設計原則：\n✅ 描述性命名：清楚表達「發生什麼事」（例如 loginSuccess 而不是 login） ✅ 攜帶必要參數：使用關聯值（Associated Values） ✅ 不包含邏輯：Action 只是「事件描述」，不執行任何邏輯 核心概念 3：Reducer（減少器） Reducer 是什麼？ Reducer 是 Redux 架構的核心，它是一個純函數（Pure Function），負責根據不同的 Action，計算並回傳新的 State。\n函數簽名：\n(State, Action) -\u0026gt; State 規則：\n✅ 純函數：相同輸入永遠產生相同輸出 ✅ 不可變性：不直接修改原 State，而是回傳新 State ✅ 無副作用：不進行 API 呼叫、不修改外部變數 完整實作 // AppReducer.swift func appReducer(state: AppState, action: AppAction) -\u0026gt; AppState { var newState = state switch action { // ============================================ // 購物車相關 // ============================================ case .updateItemQuantity(let itemId, let quantity): if let index = newState.cartItems.firstIndex(where: { $0.id == itemId }) { if quantity \u0026gt; 0 { newState.cartItems[index].quantity = quantity } else { // 數量為 0，移除商品 newState.cartItems.remove(at: index) } } // 重新計算總金額 newState.totalAmount = newState.cartItems.reduce(0) { total, item in total + (item.price * Decimal(item.quantity)) } case .confirmAddToCart: // 將選中的套餐商品加入購物車 if let packageId = newState.selectedPackageId, let package = newState.packages.first(where: { $0.id == packageId }) { for item in package.items where item.quantity \u0026gt; 0 { let cartItem = CartItem( id: item.id, name: item.name, price: 10.0, // 假設價格 quantity: item.quantity ) newState.cartItems.append(cartItem) } // 重新計算總金額 newState.totalAmount = newState.cartItems.reduce(0) { total, item in total + (item.price * Decimal(item.quantity)) } // 清空套餐選擇 newState.selectedPackageId = nil } case .clearCart: newState.cartItems = [] newState.totalAmount = 0.0 // ============================================ // 套餐相關 // ============================================ case .updatePackageItemQuantity(let packageId, let itemId, let quantity): if let packageIndex = newState.packages.firstIndex(where: { $0.id == packageId }), let itemIndex = newState.packages[packageIndex].items.firstIndex(where: { $0.id == itemId }) { newState.packages[packageIndex].items[itemIndex].quantity = max(0, quantity) } case .selectPackage(let packageId): newState.selectedPackageId = packageId // ============================================ // 用戶相關 // ============================================ case .loginSuccess(let user): newState.userProfile = user newState.isLoggedIn = true newState.isLoading = false newState.errorMessage = nil case .loginFailed(let error): newState.userProfile = nil newState.isLoggedIn = false newState.isLoading = false newState.errorMessage = error case .logout: newState.userProfile = nil newState.isLoggedIn = false newState.cartItems = [] newState.totalAmount = 0.0 // ============================================ // UI 狀態 // ============================================ case .setLoading(let loading): newState.isLoading = loading case .setError(let error): newState.errorMessage = error default: break } return newState } 為什麼叫 Reducer？ 名稱來自 JavaScript 的 Array.reduce() 方法：\n// JavaScript 範例 [1, 2, 3, 4].reduce((acc, value) =\u0026gt; acc + value, 0) // 結果: 10 在 Redux 中，我們可以想像狀態的更新過程，就像是一系列的 Action 透過 Reducer「歸納」成最新的應用狀態：\n// 概念示意 actions.reduce((當前狀態, 動作) =\u0026gt; 新狀態, 初始狀態) 因此 Reducer 就是應用邏輯的「歸納器」。\n核心概念 4：Store（儲存區） Store 是什麼？ Store 是 Redux 的核心容器，負責：\n✅ 保存應用的狀態樹（State） ✅ 提供 dispatch(action:) 方法派送 Action ✅ 呼叫 Reducer 更新狀態 ✅ 通知 View 狀態變化（透過 @Published） 基本實作 // Store.swift final class Store: ObservableObject { // 使用 @Published 讓 SwiftUI 自動觀察 @Published private(set) var state: AppState // Reducer 函數 private let reducer: (AppState, AppAction) -\u0026gt; AppState init( initialState: AppState = AppState(), reducer: @escaping (AppState, AppAction) -\u0026gt; AppState ) { self.state = initialState self.reducer = reducer } // 派送 Action func dispatch(_ action: AppAction) { print(\u0026#34;[Store] Dispatching action: \\(action)\u0026#34;) state = reducer(state, action) print(\u0026#34;[Store] New state: \\(state)\u0026#34;) } } 使用範例 // 初始化 Store let store = Store( initialState: AppState(), reducer: appReducer ) // 派送 Action store.dispatch(.loginRequest(username: \u0026#34;user\u0026#34;, password: \u0026#34;pass\u0026#34;)) store.dispatch(.updateItemQuantity(itemId: \u0026#34;123\u0026#34;, quantity: 2)) 核心概念 5：View（視圖整合） SwiftUI 整合 在 SwiftUI 中，View 透過 @EnvironmentObject 或 @ObservedObject 監聽 Store 的狀態變化。\n完整範例 // App 入口 @main struct MyApp: App { // 建立全域 Store @StateObject private var store = Store( initialState: AppState(), reducer: appReducer, middlewares: [loggingMiddleware, apiMiddleware] ) var body: some Scene { WindowGroup { ContentView() .environmentObject(store) } } } // ============================================ // 購物車 View // ============================================ struct CartView: View { @EnvironmentObject var store: Store var body: some View { List { ForEach(store.state.cartItems) { item in HStack { Text(item.name) Spacer() Text(\u0026#34;\\(item.quantity)\u0026#34;) Stepper(\u0026#34;\u0026#34;, value: Binding( get: { item.quantity }, set: { newValue in store.dispatch(.updateItemQuantity( itemId: item.id, quantity: newValue )) } )) } } // 總金額 HStack { Text(\u0026#34;總計\u0026#34;) .font(.headline) Spacer() Text(\u0026#34;$\\(store.state.totalAmount)\u0026#34;) .font(.headline) } } .navigationTitle(\u0026#34;購物車\u0026#34;) } } // ============================================ // 登入 View // ============================================ struct LoginView: View { @EnvironmentObject var store: Store @State private var username = \u0026#34;\u0026#34; @State private var password = \u0026#34;\u0026#34; var body: some View { VStack { TextField(\u0026#34;使用者名稱\u0026#34;, text: $username) .textFieldStyle(.roundedBorder) .padding() SecureField(\u0026#34;密碼\u0026#34;, text: $password) .textFieldStyle(.roundedBorder) .padding() if store.state.isLoading { ProgressView() } else { Button(\u0026#34;登入\u0026#34;) { store.dispatch(.loginRequest( username: username, password: password )) } .buttonStyle(.borderedProminent) } if let error = store.state.errorMessage { Text(error) .foregroundColor(.red) .padding() } } } } View 設計原則：\n✅ View 不直接修改 State：所有變更透過 dispatch(action) ✅ 單向資料流：View → Action → Reducer → State → View ✅ 自動更新：@Published state 變化自動觸發 View 重繪 Redux 資料流詳解 完整流程圖 流程說明：\n用戶操作：點擊「登入」按鈕 View 派送 Action：store.dispatch(.loginRequest(...)) Middleware 攔截：檢查是否需要處理非同步邏輯 執行非同步任務：呼叫 API AuthService.login() 派送成功 Action：dispatch(.loginSuccess(user)) Reducer 計算新狀態：newState.userProfile = user Store 更新狀態：state = newState（觸發 @Published） View 自動重繪：SwiftUI 偵測到 state 變化，重新計算 body Middleware（中介層）深入解析 為什麼需要 Middleware？ Reducer 必須是純函數，不能包含副作用（Side Effects）：\n❌ API 呼叫 ❌ 資料庫存取 ❌ Timer / 延遲執行 ❌ 隨機數生成 Middleware 提供了一個安全的地方來處理這些非純函數邏輯。\nMiddleware 類型定義 // Middleware.swift typealias Middleware = ( AppState, // 當前狀態 AppAction, // 當前 Action @escaping (AppAction) -\u0026gt; Void // dispatch 函數 ) -\u0026gt; Void 實作範例：日誌 Middleware // LoggingMiddleware.swift let loggingMiddleware: Middleware = { state, action, dispatch in print(\u0026#34;━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u0026#34;) print(\u0026#34;📤 [Action] \\(action)\u0026#34;) print(\u0026#34;📊 [State Before]\u0026#34;) print(\u0026#34; - isLoggedIn: \\(state.isLoggedIn)\u0026#34;) print(\u0026#34; - cartItems: \\(state.cartItems.count) items\u0026#34;) print(\u0026#34; - totalAmount: $\\(state.totalAmount)\u0026#34;) print(\u0026#34;━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u0026#34;) } 實作範例：API Middleware // APIMiddleware.swift let apiMiddleware: Middleware = { state, action, dispatch in switch action { case .loginRequest(let username, let password): // 設定 Loading 狀態 dispatch(.setLoading(true)) Task { do { // 模擬非同步 API 請求 let user = try await AuthService.login( username: username, password: password ) // 登入成功 await MainActor.run { dispatch(.loginSuccess(user: user)) } } catch { // 登入失敗 await MainActor.run { dispatch(.loginFailed(error: error.localizedDescription)) } } } default: break } } // 模擬 Auth Service struct AuthService { static func login(username: String, password: String) async throws -\u0026gt; UserProfile { // 模擬網路延遲 try await Task.sleep(nanoseconds: 1_000_000_000) // 1 秒 if username == \u0026#34;admin\u0026#34; \u0026amp;\u0026amp; password == \u0026#34;password\u0026#34; { return UserProfile( id: \u0026#34;user123\u0026#34;, name: \u0026#34;Admin User\u0026#34;, email: \u0026#34;admin@example.com\u0026#34; ) } else { throw NSError( domain: \u0026#34;AuthService\u0026#34;, code: 401, userInfo: [NSLocalizedDescriptionKey: \u0026#34;帳號或密碼錯誤\u0026#34;] ) } } } 整合 Middleware 到 Store // Store.swift（支援 Middleware） final class Store: ObservableObject { @Published private(set) var state: AppState private let reducer: (AppState, AppAction) -\u0026gt; AppState private let middlewares: [Middleware] init( initialState: AppState = AppState(), reducer: @escaping (AppState, AppAction) -\u0026gt; AppState, middlewares: [Middleware] = [] ) { self.state = initialState self.reducer = reducer self.middlewares = middlewares } func dispatch(_ action: AppAction) { // 1. 先讓 Middleware 處理 for middleware in middlewares { middleware(state, action, dispatch) } // 2. 再由 Reducer 更新狀態 state = reducer(state, action) } } 使用範例：\nlet store = Store( initialState: AppState(), reducer: appReducer, middlewares: [ loggingMiddleware, // 日誌記錄 apiMiddleware // API 呼叫 ] ) 複雜場景：串接多個 API 的策略 當需要串接多個 API（例如先登入 → 取得用戶資料 → 載入訂單列表），有三種主流做法：\n策略比較 策略 1：巢狀呼叫 流程圖：\n程式碼：\nlet nestedMiddleware: Middleware = { state, action, dispatch in switch action { case .loginRequest(let username, let password): Task { // 1. 登入 let user = try await AuthService.login(username: username, password: password) dispatch(.loginSuccess(user: user)) // 2. 取得用戶資料（巢狀） let userData = try await UserService.getUserData(userId: user.id) dispatch(.userDataLoaded(userData)) // 3. 載入訂單（巢狀） let orders = try await OrderService.getOrders(userId: user.id) dispatch(.ordersLoaded(orders)) } default: break } } 優點：\n✅ 邏輯集中在一處 缺點：\n❌ 程式碼層層巢狀，難以維護 ❌ 錯誤處理複雜 ❌ 無法單獨測試各階段 策略 2：合併 Action 流程圖：\n程式碼：\nenum AppAction { case initializeUserSession(username: String, password: String) case sessionInitialized(user: UserProfile, orders: [Order]) // ... } let combinedMiddleware: Middleware = { state, action, dispatch in switch action { case .initializeUserSession(let username, let password): Task { let user = try await AuthService.login(username: username, password: password) let userData = try await UserService.getUserData(userId: user.id) let orders = try await OrderService.getOrders(userId: user.id) // 一次性派發包含所有資料的 Action dispatch(.sessionInitialized(user: user, orders: orders)) } default: break } } 優點：\n✅ 流程扁平，一次處理 缺點：\n❌ Action 語意過於龐大（違反單一職責） ❌ 無法追蹤中間狀態 ❌ 難以在 UI 顯示進度（例如「載入訂單中\u0026hellip;」） 策略 3：多層 Middleware（推薦） 流程圖：\n程式碼：\n// ============================================ // Middleware 1: 處理登入 // ============================================ let loginMiddleware: Middleware = { state, action, dispatch in switch action { case .loginRequest(let username, let password): dispatch(.setLoading(true)) Task { do { let user = try await AuthService.login(username: username, password: password) await MainActor.run { dispatch(.loginSuccess(user: user)) } } catch { await MainActor.run { dispatch(.loginFailed(error: error.localizedDescription)) } } } default: break } } // ============================================ // Middleware 2: 偵測登入成功，載入用戶資料 // ============================================ let userDataMiddleware: Middleware = { state, action, dispatch in switch action { case .loginSuccess(let user): Task { let userData = try await UserService.getUserData(userId: user.id) await MainActor.run { dispatch(.userDataLoaded(userData)) } } default: break } } // ============================================ // Middleware 3: 偵測資料載入完成，載入訂單 // ============================================ let ordersMiddleware: Middleware = { state, action, dispatch in switch action { case .userDataLoaded: guard let userId = state.userProfile?.id else { return } Task { let orders = try await OrderService.getOrders(userId: userId) await MainActor.run { dispatch(.ordersLoaded(orders)) dispatch(.setLoading(false)) } } default: break } } 使用：\nlet store = Store( initialState: AppState(), reducer: appReducer, middlewares: [ loggingMiddleware, loginMiddleware, // 處理登入 userDataMiddleware, // 偵測登入成功 → 載入用戶資料 ordersMiddleware // 偵測資料載入 → 載入訂單 ] ) 優點：\n✅ 模組化清楚：每個 Middleware 只負責一件事 ✅ 易於測試：可單獨測試各 Middleware ✅ 可追蹤進度：每個階段都有對應的 Action ✅ 易於擴展：新增功能只需新增 Middleware 缺點：\n⚠️ 需要良好的設計，避免 Middleware 之間過度耦合 最佳實踐與建議 1. Reducer 設計 // ✅ 好的做法 func appReducer(state: AppState, action: AppAction) -\u0026gt; AppState { var newState = state switch action { case .loginSuccess(let user): newState.userProfile = user newState.isLoggedIn = true return newState // 明確回傳 default: return state // 未知 Action 回傳原狀態 } } // ❌ 避免的做法 func badReducer(state: AppState, action: AppAction) -\u0026gt; AppState { switch action { case .loginSuccess(let user): state.userProfile = user // ❌ 直接修改 state（如果是 class） return state default: return AppState() // ❌ 回傳新的初始狀態，會丟失其他資料 } } 2. State 結構設計 // ✅ 扁平化設計 struct AppState { var user: UserProfile? var cartItemIds: [String] // 只存 ID var cartItemsById: [String: CartItem] // ID 對應實體 } // ❌ 過深的巢狀 struct BadAppState { var data: DataContainer } struct DataContainer { var user: UserContainer } struct UserContainer { var profile: UserProfile } 3. Middleware 錯誤處理 let safeAPIMiddleware: Middleware = { state, action, dispatch in switch action { case .loginRequest(let username, let password): Task { do { let user = try await AuthService.login(username: username, password: password) await MainActor.run { dispatch(.loginSuccess(user: user)) } } catch let error as NSError { // ✅ 詳細錯誤處理 await MainActor.run { let errorMessage: String switch error.code { case 401: errorMessage = \u0026#34;帳號或密碼錯誤\u0026#34; case 500: errorMessage = \u0026#34;伺服器錯誤，請稍後再試\u0026#34; default: errorMessage = error.localizedDescription } dispatch(.loginFailed(error: errorMessage)) } } } default: break } } 4. 測試範例 import XCTest class AppReducerTests: XCTestCase { func testLoginSuccess() { // Given let initialState = AppState() let user = UserProfile(id: \u0026#34;123\u0026#34;, name: \u0026#34;Test User\u0026#34;, email: \u0026#34;test@example.com\u0026#34;) let action = AppAction.loginSuccess(user: user) // When let newState = appReducer(state: initialState, action: action) // Then XCTAssertTrue(newState.isLoggedIn) XCTAssertEqual(newState.userProfile?.name, \u0026#34;Test User\u0026#34;) XCTAssertNil(newState.errorMessage) } func testCartQuantityUpdate() { // Given var initialState = AppState() initialState.cartItems = [ CartItem(id: \u0026#34;item1\u0026#34;, name: \u0026#34;商品A\u0026#34;, price: 10.0, quantity: 1) ] // When let newState = appReducer( state: initialState, action: .updateItemQuantity(itemId: \u0026#34;item1\u0026#34;, quantity: 3) ) // Then XCTAssertEqual(newState.cartItems.first?.quantity, 3) XCTAssertEqual(newState.totalAmount, 30.0) } } 進階主題：組合 Reducer 當應用規模擴大時，可以將大型 Reducer 拆分為多個小 Reducer：\n// 購物車 Reducer func cartReducer(state: AppState, action: AppAction) -\u0026gt; AppState { var newState = state switch action { case .updateItemQuantity, .confirmAddToCart, .clearCart: // 處理購物車相關邏輯 // ... return newState default: return state } } // 用戶 Reducer func userReducer(state: AppState, action: AppAction) -\u0026gt; AppState { var newState = state switch action { case .loginSuccess, .loginFailed, .logout: // 處理用戶相關邏輯 // ... return newState default: return state } } // 組合 Reducer func appReducer(state: AppState, action: AppAction) -\u0026gt; AppState { var newState = state // 依序執行各子 Reducer newState = cartReducer(state: newState, action: action) newState = userReducer(state: newState, action: action) return newState } 結論：Redux 的價值 關鍵優勢 ✅ 可預測性\n狀態變化有明確的流程：Action → Reducer → State 相同的 Action 序列永遠產生相同的 State ✅ 可測試性\nReducer 是純函數，易於單元測試 不需要 Mock，直接測試輸入輸出 ✅ 可維護性\n狀態變化邏輯集中在 Reducer 單向資料流，易於追蹤問題 ✅ 可除錯性\n透過 Logging Middleware 追蹤所有 Action Time-travel debugging（重播 Action 序列） 適用場景 ✅ 推薦使用 Redux：\n中大型應用（多個 View 共享狀態） 複雜的狀態邏輯 需要詳細的狀態變化追蹤 團隊協作專案 ⚠️ 可能過度設計：\n簡單的小型應用 狀態變化簡單明確 單頁面應用 最後建議 Redux 不是銀彈，但在適當的場景下，它能大幅提升應用的可維護性與可預測性。當專案逐漸龐大時，Redux 的價值會愈發明顯。\n建議學習路徑：\n先掌握 Reducer 與 Store 的基本概念 理解單向資料流 實作簡單的 Middleware 嘗試多層 Middleware 的設計 學習組合 Reducer 的技巧 參考資源 Redux 官方文件 SwiftUI + Redux 最佳實踐 Combine Framework ReSwift（Swift Redux 框架） ","permalink":"https://peter-notes.com/posts/swift-redux-architecture/","summary":"\u003ch2 id=\"引言為什麼需要-redux\"\u003e引言：為什麼需要 Redux？\u003c/h2\u003e\n\u003cp\u003e在 iOS 開發中，隨著應用規模擴大，\u003cstrong\u003e狀態管理\u003c/strong\u003e逐漸成為最具挑戰性的課題。當多個 View 需要共享狀態、狀態變化難以追蹤時，應用很容易陷入混亂。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eRedux\u003c/strong\u003e 作為一種\u003cstrong\u003e可預測的狀態容器\u003c/strong\u003e，最早在 JavaScript 生態系中流行，如今也廣泛應用於 Swift/iOS 專案。本文將深入介紹 Redux 架構的核心觀念，包含：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eReducer（減少器）\u003c/strong\u003e：狀態更新的核心邏輯\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eStore（儲存區）\u003c/strong\u003e：應用的單一狀態來源\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eAction（動作）\u003c/strong\u003e：描述「發生什麼事」的指令\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eMiddleware（中介層）\u003c/strong\u003e：處理非同步與副作用\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"redux-核心架構概覽\"\u003eRedux 核心架構概覽\u003c/h2\u003e\n\u003ch3 id=\"架構組成\"\u003e架構組成\u003c/h3\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/2025-08-21-swift-redux-架構核心觀念解析reducer-與其他關鍵組件-diagram-d79aecec.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e\u003cstrong\u003e架構特性：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e✅ \u003cstrong\u003e單向資料流\u003c/strong\u003e：資料流向可預測\u003c/li\u003e\n\u003cli\u003e✅ \u003cstrong\u003e單一狀態來源\u003c/strong\u003e：整個應用只有一個 State 樹\u003c/li\u003e\n\u003cli\u003e✅ \u003cstrong\u003e狀態不可變\u003c/strong\u003e：不直接修改 State，而是創建新 State\u003c/li\u003e\n\u003cli\u003e✅ \u003cstrong\u003e可測試性高\u003c/strong\u003e：Reducer 是純函數，易於測試\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"核心概念-1state狀態\"\u003e核心概念 1：State（狀態）\u003c/h2\u003e\n\u003ch3 id=\"state-是什麼\"\u003eState 是什麼？\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eState\u003c/strong\u003e 是整個應用的\u003cstrong\u003e單一資料來源（Single Source of Truth）\u003c/strong\u003e。它通常是一個 \u003ccode\u003estruct\u003c/code\u003e，描述當前應用的完整狀態。\u003c/p\u003e\n\u003ch3 id=\"實作範例\"\u003e實作範例\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-swift\" data-lang=\"swift\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// AppState.swift\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"nc\"\u003eAppState\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"c1\"\u003e// 購物車\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003ecartItems\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"n\"\u003eCartItem\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003etotalAmount\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eDecimal\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"mf\"\u003e0.0\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"c1\"\u003e// 用戶資訊\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003euserProfile\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eUserProfile\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eisLoggedIn\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003eBool\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"c1\"\u003e// UI 狀態\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eisLoading\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003eBool\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eerrorMessage\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"c1\"\u003e// 套餐選擇\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003epackages\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"n\"\u003ePackage\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eselectedPackageId\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// 購物車商品\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"nc\"\u003eCartItem\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eIdentifiable\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eid\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003eString\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003eString\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eprice\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eDecimal\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003equantity\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003eInt\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// 用戶資料\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"nc\"\u003eUserProfile\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eid\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003eString\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003eString\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eemail\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003eString\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// 套餐\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"nc\"\u003ePackage\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eIdentifiable\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eid\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003eString\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003eString\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eitems\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"n\"\u003ePackageItem\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"nc\"\u003ePackageItem\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eIdentifiable\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eid\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003eString\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003eString\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003equantity\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003eInt\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e設計原則：\u003c/strong\u003e\u003c/p\u003e","title":"Swift Redux 架構完整指南：從 Reducer 到 Middleware 的狀態管理實踐"},{"content":"前言：編輯器與前端的排版不一致之謎 在開發多平台醫療健康應用時，我們採用了現代化的技術棧：\n後端 CMS：Strapi v5.15.1（Headless CMS） 前端框架：Vue.js 3 富文本編輯器：CKEditor 5 資料傳輸：GraphQL API 這個組合在大多數情況下運作良好，編輯者可以在 Strapi 後台使用 CKEditor 輕鬆編輯富文本內容，前端 Vue.js 應用透過 GraphQL 獲取並渲染這些內容。\n然而，我們遇到了一個令人困惑的問題：\n在 Strapi 後台使用 CKEditor 精心排版的水平並排圖片，到了前端網頁卻變成了垂直排列。\n這個問題不僅影響了內容的視覺呈現，也破壞了編輯者的排版意圖。更重要的是，這讓非技術背景的內容編輯者感到困惑：「為什麼我在後台看到的排版，到了網站上就變了？」\n這篇文章將深入探討這個問題的根本原因，並提供系統性的解決方案。\n問題現象與環境說明 內容流程架構 我們的內容從編輯到展示的完整流程如下：\n問題的具體表現 預期行為：\n在 Strapi CKEditor 後台，編輯者將兩張圖片設定為水平並排：\n\u0026lt;!-- CKEditor 生成的 HTML 結構 --\u0026gt; \u0026lt;figure class=\u0026#34;image\u0026#34; style=\u0026#34;float:left\u0026#34;\u0026gt; \u0026lt;img src=\u0026#34;/uploads/image1.webp\u0026#34; alt=\u0026#34;圖片1\u0026#34;\u0026gt; \u0026lt;figcaption\u0026gt;圖片1說明\u0026lt;/figcaption\u0026gt; \u0026lt;/figure\u0026gt; \u0026lt;figure class=\u0026#34;image\u0026#34; style=\u0026#34;float:left\u0026#34;\u0026gt; \u0026lt;img src=\u0026#34;/uploads/image2.webp\u0026#34; alt=\u0026#34;圖片2\u0026#34;\u0026gt; \u0026lt;figcaption\u0026gt;圖片2說明\u0026lt;/figcaption\u0026gt; \u0026lt;/figure\u0026gt; 實際現象：\n前端 Vue.js 渲染後，圖片變成垂直排列：\n兩張圖片沒有並排顯示，而是一張接著一張垂直堆疊。\n技術環境詳情 Strapi CKEditor 配置（config/schema.json）：\n{ \u0026#34;kind\u0026#34;: \u0026#34;collectionType\u0026#34;, \u0026#34;collectionName\u0026#34;: \u0026#34;articles\u0026#34;, \u0026#34;info\u0026#34;: { \u0026#34;singularName\u0026#34;: \u0026#34;article\u0026#34;, \u0026#34;pluralName\u0026#34;: \u0026#34;articles\u0026#34;, \u0026#34;displayName\u0026#34;: \u0026#34;文章\u0026#34; }, \u0026#34;attributes\u0026#34;: { \u0026#34;title\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;string\u0026#34; }, \u0026#34;content\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;customField\u0026#34;, \u0026#34;options\u0026#34;: { \u0026#34;preset\u0026#34;: \u0026#34;defaultHtml\u0026#34; }, \u0026#34;customField\u0026#34;: \u0026#34;plugin::ckeditor5.CKEditor\u0026#34; } } } Vue.js 前端渲染組件（ServiceDetailView.vue）：\n\u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;container mx-auto px-4\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-3xl font-bold mb-6\u0026#34;\u0026gt;{{ article.title }}\u0026lt;/h1\u0026gt; \u0026lt;!-- 問題所在：內容容器 --\u0026gt; \u0026lt;div class=\u0026#34;w-full max-w-[1200px] lg:w-[80%] min-h-[300px]\u0026#34; v-if=\u0026#34;processedContent\u0026#34; \u0026gt; \u0026lt;div v-html=\u0026#34;processedContent\u0026#34; class=\u0026#34;text-[#666666] ck-editor-content\u0026#34; \u0026gt;\u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { ref, computed } from \u0026#39;vue\u0026#39;; import { useQuery } from \u0026#39;@vue/apollo-composable\u0026#39;; import gql from \u0026#39;graphql-tag\u0026#39;; const ARTICLE_QUERY = gql` query GetArticle($id: ID!) { article(id: $id) { data { attributes { title content } } } } `; const { result } = useQuery(ARTICLE_QUERY, { id: route.params.id }); const processedContent = computed(() =\u0026gt; { return result.value?.article?.data?.attributes?.content || \u0026#39;\u0026#39;; }); \u0026lt;/script\u0026gt; 現有的 CSS 樣式（src/assets/main.css）：\n/* CKEditor 內容樣式支援 */ .ck-editor-content figure[style*=\u0026#34;float:left\u0026#34;] { float: left !important; margin-right: 1em; max-width: 48%; } .ck-editor-content figure[style*=\u0026#34;float:right\u0026#34;] { float: right !important; margin-left: 1em; max-width: 48%; } .ck-editor-content figure.image { margin: 1em 0; } .ck-editor-content img { max-width: 100%; height: auto; } 問題調查過程 第一步：確認 CKEditor 輸出正確 首先，我們需要確認問題不是出在 CKEditor 本身。\n檢查方法：在 Strapi 後台檢查儲存的原始 HTML\n// 使用 Strapi 的 REST API 直接查詢 fetch(\u0026#39;http://localhost:1337/api/articles/1\u0026#39;) .then(res =\u0026gt; res.json()) .then(data =\u0026gt; { console.log(\u0026#39;原始 HTML:\u0026#39;, data.data.attributes.content); }); 檢查結果：\n\u0026lt;figure class=\u0026#34;image\u0026#34; style=\u0026#34;float:left\u0026#34;\u0026gt; \u0026lt;img src=\u0026#34;/uploads/image1_abc123.webp\u0026#34; alt=\u0026#34;圖片1\u0026#34;\u0026gt; \u0026lt;/figure\u0026gt; \u0026lt;figure class=\u0026#34;image\u0026#34; style=\u0026#34;float:left\u0026#34;\u0026gt; \u0026lt;img src=\u0026#34;/uploads/image2_def456.webp\u0026#34; alt=\u0026#34;圖片2\u0026#34;\u0026gt; \u0026lt;/figure\u0026gt; ✅ 確認：CKEditor 正確生成了含有 float:left 樣式的 HTML。\n第二步：檢查 GraphQL 資料傳輸 確認 GraphQL API 沒有修改或破壞 HTML 內容。\n檢查方法：使用 GraphQL Playground 查詢\nquery { article(id: \u0026#34;1\u0026#34;) { data { attributes { content } } } } 回應結果：\n{ \u0026#34;data\u0026#34;: { \u0026#34;article\u0026#34;: { \u0026#34;data\u0026#34;: { \u0026#34;attributes\u0026#34;: { \u0026#34;content\u0026#34;: \u0026#34;\u0026lt;figure class=\\\u0026#34;image\\\u0026#34; style=\\\u0026#34;float:left\\\u0026#34;\u0026gt;...\u0026lt;/figure\u0026gt;...\u0026#34; } } } } } ✅ 確認：GraphQL 正確傳輸了 HTML 內容，沒有修改。\n第三步：檢查前端渲染 使用瀏覽器開發者工具檢查實際渲染的 DOM 結構。\n檢查方法：\n打開瀏覽器開發者工具（F12） 檢查渲染後的 HTML 結構 查看計算後的 CSS 樣式（Computed Styles） 發現的關鍵資訊：\n\u0026lt;!-- 實際渲染的 DOM --\u0026gt; \u0026lt;div class=\u0026#34;w-full max-w-[1200px] lg:w-[80%]\u0026#34; style=\u0026#34;width: 960px;\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;ck-editor-content\u0026#34;\u0026gt; \u0026lt;figure class=\u0026#34;image\u0026#34; style=\u0026#34;float:left; max-width: 48%;\u0026#34;\u0026gt; \u0026lt;img src=\u0026#34;...\u0026#34; style=\u0026#34;width: 460.8px;\u0026#34;\u0026gt; \u0026lt;/figure\u0026gt; \u0026lt;figure class=\u0026#34;image\u0026#34; style=\u0026#34;float:left; max-width: 48%;\u0026#34;\u0026gt; \u0026lt;img src=\u0026#34;...\u0026#34; style=\u0026#34;width: 460.8px;\u0026#34;\u0026gt; \u0026lt;/figure\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; 計算結果：\n容器實際寬度：960px（80% of 1200px） 每張圖片寬度：960px × 48% = 460.8px 兩張圖片總寬度：460.8px × 2 = 921.6px 圖片間距：1em（約 16px） 總需求寬度：921.6px + 16px = 937.6px 看起來空間應該足夠，但為什麼還是垂直排列？\n第四步：深入分析 CSS 盒模型 問題的關鍵在於 CSS 盒模型的計算細節。\n進一步檢查發現：\n使用 Chrome DevTools 的 \u0026ldquo;Computed\u0026rdquo; 面板檢查實際計算值：\n\u0026lt;figure\u0026gt; 的 box-sizing：預設為 content-box 圖片的實際渲染寬度：由於 max-width: 100%，圖片可能會根據自身比例調整 關鍵發現：\n在某些螢幕尺寸下，容器的實際可用寬度會因為父元素的限制而小於計算值。特別是在使用 Tailwind 的 lg:w-[80%] 時，實際寬度會根據視窗大小動態變化。\n第五步：找到根本原因 經過詳細分析，我們找到了問題的根本原因：\n根本原因：容器寬度限制 + CSS 浮動空間計算的臨界問題\n容器寬度受限：max-w-[1200px] lg:w-[80%] 在大螢幕上的實際寬度不夠穩定 浮動空間計算：兩個 48% 寬度的浮動元素 + 邊距，在臨界情況下容易觸發換行 瀏覽器渲染誤差：浮動佈局對亞像素（sub-pixel）計算敏感，可能因為小數點四捨五入導致空間不足 CSS 浮動佈局的深入原理 要徹底理解這個問題，我們需要深入了解 CSS 浮動佈局的運作機制。\n浮動元素的定位規則 CSS float 屬性會讓元素脫離正常文檔流，並沿著容器的左側或右側浮動。\n基本規則：\n浮動元素會盡可能向左或向右移動，直到碰到容器邊緣或另一個浮動元素 後續的浮動元素會嘗試與前面的浮動元素並排，如果空間不足則換行 浮動元素的寬度影響後續元素的位置 空間計算公式：\n可用寬度 = 容器寬度 - (所有浮動元素寬度之和) - (所有間距之和) 如果 可用寬度 \u0026lt; 0，則後續浮動元素會換行。\n盒模型對浮動的影響 CSS 盒模型決定了元素的實際佔用空間。\n兩種盒模型：\nbox-sizing: content-box（預設值）\n元素寬度 = width 實際佔用 = width + padding + border + margin box-sizing: border-box\n元素寬度 = width（包含 padding 和 border） 實際佔用 = width + margin 問題案例分析：\n/* CKEditor 生成的 figure 預設使用 content-box */ figure.image { max-width: 48%; /* 460.8px */ margin-right: 1em; /* 16px */ padding: 0; /* 無 padding */ border: none; /* 無 border */ } 理論上，兩個這樣的元素總佔用為：\n(48% + 1em) + 48% = 96% + 16px 如果容器寬度為 1200px：1152px + 16px = 1168px 應該可以並排（1168px \u0026lt; 1200px） 但實際上，瀏覽器在計算百分比時可能會有小數點誤差，導致實際佔用略大於計算值。\n亞像素渲染問題 現代瀏覽器支援亞像素渲染，但在浮動佈局中，小數像素可能導致意外的換行。\n範例：\n容器寬度：1200px Figure 1 寬度：48% = 1200 × 0.48 = 576px Figure 1 margin-right：16px Figure 2 寬度：48% = 576px 總需求：576 + 16 + 576 = 1168px 但在實際渲染時：\nFigure 1 實際寬度：576.5px（四捨五入） Figure 2 實際寬度：576.5px 總需求：576.5 + 16 + 576.5 = 1169px 只差 1px，但瀏覽器會判定空間不足，觸發換行。\n解決方案設計 基於以上分析，我們設計了多層次的解決方案。\n方案一：擴大容器寬度（主要解決方案） 這是最直接且有效的解決方案。\n修改前：\n\u0026lt;div class=\u0026#34;w-full max-w-[1200px] lg:w-[80%] min-h-[300px]\u0026#34;\u0026gt; \u0026lt;div v-html=\u0026#34;processedContent\u0026#34; class=\u0026#34;ck-editor-content\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; 修改後：\n\u0026lt;div class=\u0026#34;w-full max-w-[1400px] lg:w-[95%] min-h-[300px]\u0026#34;\u0026gt; \u0026lt;div v-html=\u0026#34;processedContent\u0026#34; class=\u0026#34;ck-editor-content\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; 改進說明：\n最大寬度：從 1200px 增加到 1400px\n提供更多空間給並排圖片 仍然保持良好的閱讀寬度 大螢幕比例：從 80% 增加到 95%\n充分利用大螢幕空間 減少左右留白過多的問題 效果驗證：\n容器寬度：1400px Figure 1：48% = 672px + 16px margin = 688px Figure 2：48% = 672px 總需求：1360px 剩餘空間：1400px - 1360px = 40px ✅ 足夠安全邊距 方案二：優化圖片寬度比例（輔助方案） 進一步優化 CSS，提供更多安全空間。\n修改前的 CSS：\n.ck-editor-content figure[style*=\u0026#34;float:left\u0026#34;] { float: left !important; margin-right: 1em; max-width: 48%; } 修改後的 CSS：\n/* 優化後的 CSS */ .ck-editor-content figure[style*=\u0026#34;float:left\u0026#34;], .ck-editor-content figure[style*=\u0026#34;float: left\u0026#34;] { float: left !important; margin-right: 1em; max-width: 45%; /* 從 48% 調整為 45% */ box-sizing: border-box; /* 確保一致的盒模型 */ } .ck-editor-content figure[style*=\u0026#34;float:right\u0026#34;], .ck-editor-content figure[style*=\u0026#34;float: right\u0026#34;] { float: right !important; margin-left: 1em; max-width: 45%; box-sizing: border-box; } /* 支援段落內的並排圖片 */ .ck-editor-content p figure.image { display: inline-block !important; vertical-align: top; margin: 0.5em; } /* 改善相鄰圖片間距 */ .ck-editor-content figure.image + figure.image { margin-left: 0.5em; } /* 清除浮動 */ .ck-editor-content::after, .ck-editor-content \u0026gt; *::after { content: \u0026#34;\u0026#34;; display: table; clear: both; } 改進重點：\n寬度調整：從 48% 減少到 45%\n留出 10% 的安全空間（5% × 2 + 間距） 避免臨界計算誤差 統一盒模型：強制使用 border-box\n確保所有瀏覽器計算一致 清除浮動：加入 ::after 偽元素\n防止浮動影響後續內容 相容性增強：同時匹配 float:left 和 float: left（有無空格）\n方案三：響應式斷點處理（體驗優化） 針對不同螢幕尺寸提供最佳顯示效果。\n/* 小螢幕：垂直排列 */ @media (max-width: 768px) { .ck-editor-content figure[style*=\u0026#34;float\u0026#34;] { float: none !important; max-width: 100% !important; margin: 1em 0 !important; } } /* 中等螢幕：保持並排但調整比例 */ @media (min-width: 769px) and (max-width: 1024px) { .ck-editor-content figure[style*=\u0026#34;float:left\u0026#34;], .ck-editor-content figure[style*=\u0026#34;float: left\u0026#34;] { max-width: 46%; } .ck-editor-content figure[style*=\u0026#34;float:right\u0026#34;], .ck-editor-content figure[style*=\u0026#34;float: right\u0026#34;] { max-width: 46%; } } /* 大螢幕：標準並排 */ @media (min-width: 1025px) { .ck-editor-content figure[style*=\u0026#34;float:left\u0026#34;], .ck-editor-content figure[style*=\u0026#34;float: left\u0026#34;] { max-width: 45%; } .ck-editor-content figure[style*=\u0026#34;float:right\u0026#34;], .ck-editor-content figure[style*=\u0026#34;float: right\u0026#34;] { max-width: 45%; } } 響應式策略：\n螢幕尺寸 策略 圖片寬度 原因 \u0026lt; 768px 垂直排列 100% 小螢幕空間不足，並排影響閱讀 769px - 1024px 並排顯示 46% 中等螢幕，適度縮減寬度確保並排 \u0026gt; 1025px 並排顯示 45% 大螢幕，保留足夠空間 方案四：完整的 CKEditor 樣式支援 除了圖片排版，還需要支援 CKEditor 的其他功能。\n/* ===== CKEditor 5 完整樣式支援 ===== */ /* 基礎容器 */ .ck-editor-content { width: 100%; overflow: hidden; /* 清除浮動 */ line-height: 1.8; font-size: 16px; } /* 段落樣式 */ .ck-editor-content p { margin-bottom: 1.2em; } /* 標題樣式 */ .ck-editor-content h2 { font-size: 1.8em; font-weight: 700; margin: 1.5em 0 0.8em; line-height: 1.4; } .ck-editor-content h3 { font-size: 1.5em; font-weight: 600; margin: 1.3em 0 0.7em; } /* 圖片基礎樣式 */ .ck-editor-content figure.image { max-width: 100%; margin: 1.5em 0; box-sizing: border-box; } .ck-editor-content figure.image img { max-width: 100%; height: auto; display: block; } /* 圖片說明文字 */ .ck-editor-content figure.image figcaption { font-size: 0.9em; color: #666; text-align: center; margin-top: 0.5em; font-style: italic; } /* 圖片對齊 - 左浮動 */ .ck-editor-content figure[style*=\u0026#34;float:left\u0026#34;], .ck-editor-content figure[style*=\u0026#34;float: left\u0026#34;] { float: left !important; margin-right: 1.5em; margin-bottom: 1em; max-width: 45%; } /* 圖片對齊 - 右浮動 */ .ck-editor-content figure[style*=\u0026#34;float:right\u0026#34;], .ck-editor-content figure[style*=\u0026#34;float: right\u0026#34;] { float: right !important; margin-left: 1.5em; margin-bottom: 1em; max-width: 45%; } /* 圖片對齊 - 置中 */ .ck-editor-content figure.image.image_resized { margin-left: auto; margin-right: auto; } /* 清除浮動 */ .ck-editor-content::after { content: \u0026#34;\u0026#34;; display: table; clear: both; } /* 列表樣式 */ .ck-editor-content ul, .ck-editor-content ol { margin: 1em 0; padding-left: 2em; } .ck-editor-content ul li { list-style-type: disc; margin-bottom: 0.5em; } .ck-editor-content ol li { list-style-type: decimal; margin-bottom: 0.5em; } /* 引用區塊 */ .ck-editor-content blockquote { border-left: 4px solid #e5e7eb; padding-left: 1.5em; margin: 1.5em 0; color: #6b7280; font-style: italic; } /* 程式碼區塊 */ .ck-editor-content pre { background-color: #f3f4f6; border-radius: 0.5rem; padding: 1em; overflow-x: auto; margin: 1.5em 0; } .ck-editor-content code { font-family: \u0026#39;Courier New\u0026#39;, monospace; font-size: 0.9em; } /* 表格樣式 */ .ck-editor-content table { width: 100%; border-collapse: collapse; margin: 1.5em 0; } .ck-editor-content table td, .ck-editor-content table th { border: 1px solid #e5e7eb; padding: 0.75em; text-align: left; } .ck-editor-content table th { background-color: #f9fafb; font-weight: 600; } /* 水平分隔線 */ .ck-editor-content hr { border: none; border-top: 2px solid #e5e7eb; margin: 2em 0; } /* 連結樣式 */ .ck-editor-content a { color: #3b82f6; text-decoration: underline; } .ck-editor-content a:hover { color: #2563eb; } 實際部署與驗證 完整的程式碼修改 1. 修改 Vue.js 組件（ServiceDetailView.vue）：\n\u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;container mx-auto px-4 py-8\u0026#34;\u0026gt; \u0026lt;!-- 文章標題 --\u0026gt; \u0026lt;h1 class=\u0026#34;text-3xl font-bold mb-6 text-gray-900\u0026#34;\u0026gt; {{ article?.title }} \u0026lt;/h1\u0026gt; \u0026lt;!-- 內容容器 - 已優化寬度 --\u0026gt; \u0026lt;div class=\u0026#34;w-full max-w-[1400px] lg:w-[95%] mx-auto min-h-[300px]\u0026#34; v-if=\u0026#34;processedContent\u0026#34; \u0026gt; \u0026lt;div v-html=\u0026#34;processedContent\u0026#34; class=\u0026#34;ck-editor-content text-gray-700\u0026#34; \u0026gt;\u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;!-- 載入中狀態 --\u0026gt; \u0026lt;div v-else class=\u0026#34;flex justify-center items-center min-h-[300px]\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { ref, computed } from \u0026#39;vue\u0026#39;; import { useRoute } from \u0026#39;vue-router\u0026#39;; import { useQuery } from \u0026#39;@vue/apollo-composable\u0026#39;; import gql from \u0026#39;graphql-tag\u0026#39;; const route = useRoute(); const ARTICLE_QUERY = gql` query GetArticle($id: ID!) { article(id: $id) { data { id attributes { title content publishedAt } } } } `; const { result, loading, error } = useQuery( ARTICLE_QUERY, { id: route.params.id } ); const article = computed(() =\u0026gt; result.value?.article?.data?.attributes); const processedContent = computed(() =\u0026gt; { if (!article.value?.content) return \u0026#39;\u0026#39;; // 可以在這裡進行額外的 HTML 處理 let content = article.value.content; // 例如：替換圖片路徑 content = content.replace( /src=\u0026#34;\\/uploads\\//g, \u0026#39;src=\u0026#34;\u0026#39; + import.meta.env.VITE_STRAPI_URL + \u0026#39;/uploads/\u0026#39; ); return content; }); \u0026lt;/script\u0026gt; 2. 更新 CSS 樣式（src/assets/main.css）：\n將上述完整的 CSS 樣式加入到專案的 main.css 檔案中。\n驗證測試清單 測試結果：\n實施解決方案後的驗證結果：\n✅ 後台編輯：CKEditor 中的水平排版設定正常 ✅ API 傳輸：GraphQL 正確傳輸 HTML 內容 ✅ 前端渲染：圖片在前端正確並排顯示 ✅ 響應式相容：在不同螢幕尺寸下都能正常工作 ✅ 跨瀏覽器：Chrome、Firefox、Safari、Edge 測試通過 ✅ 編輯體驗一致：後台編輯效果與前端顯示效果一致 ✅ 不影響其他內容：文字、標題、列表等其他元素顯示正常\n最佳實踐與經驗總結 容器寬度設計原則 原則 1：為富文本內容提供充足空間\n/* ✅ 推薦：給富文本內容足夠的寬度 */ .rich-text-container { max-width: 1400px; /* 足夠的最大寬度 */ width: 95%; /* 大螢幕使用更多空間 */ margin: 0 auto; /* 居中對齊 */ padding: 0 1rem; /* 適當的內距 */ } /* ❌ 不推薦：過度限制寬度 */ .narrow-container { max-width: 800px; /* 對並排圖片來說太窄 */ width: 70%; /* 浪費大螢幕空間 */ } 原則 2：考慮內容類型選擇寬度\n內容類型 建議最大寬度 原因 純文字文章 800px 最佳閱讀寬度 圖文混排 1200px 支援圖片並排 富文本內容 1400px 支援複雜排版 技術文件 1600px 支援程式碼、圖表 CKEditor 整合檢查清單 後端 Strapi 設定：\n安裝並啟用 @_sh/strapi-plugin-ckeditor 外掛 在 Schema 中正確設定 customField: \u0026quot;plugin::ckeditor5.CKEditor\u0026quot; 設定 CKEditor 的 preset（建議使用 defaultHtml） 確認圖片上傳功能正常運作 前端 CSS 支援：\n支援圖片浮動對齊（float: left/right） 支援行內圖片（display: inline-block） 支援圖片尺寸控制（width, max-width） 支援清除浮動（clear: both） 支援圖片說明文字（figcaption） 支援響應式圖片（媒體查詢） 支援表格、列表、引用等其他元素 測試流程：\n在 CKEditor 中測試各種排版組合 確認所有排版在前端正確顯示 在不同螢幕尺寸下測試（手機、平板、桌機） 跨瀏覽器測試（Chrome、Firefox、Safari、Edge） 測試圖片載入效能 測試內容編輯與更新流程 預防性 CSS 設計策略 策略 1：使用防禦性 CSS\n/* 防禦性設計：確保容器有足夠空間 */ .ck-editor-content { width: 100%; overflow: hidden; /* 清除浮動 */ box-sizing: border-box; } /* 防禦性設計：確保圖片不會溢出 */ .ck-editor-content img { max-width: 100%; height: auto; display: block; } /* 防禦性設計：統一盒模型 */ .ck-editor-content * { box-sizing: border-box; } 策略 2：使用 CSS 變數提升維護性\n:root { --ck-content-max-width: 1400px; --ck-content-width: 95%; --ck-figure-side-width: 45%; --ck-figure-margin: 1.5em; --ck-line-height: 1.8; } .rich-text-container { max-width: var(--ck-content-max-width); width: var(--ck-content-width); } .ck-editor-content figure[style*=\u0026#34;float\u0026#34;] { max-width: var(--ck-figure-side-width); margin-right: var(--ck-figure-margin); } 策略 3：使用現代 CSS 特性\n/* 使用 CSS Grid 作為備選方案 */ @supports (display: grid) { .ck-editor-content-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; } .ck-editor-content-grid figure { float: none !important; max-width: 100% !important; } } 結語 這個看似簡單的圖片排版問題，實際上涉及了多個層面的技術細節：\nCKEditor 的 HTML 生成機制：理解富文本編輯器如何產生 HTML CSS 浮動佈局的原理：深入了解浮動元素的定位規則 盒模型與空間計算：精確計算元素的實際佔用空間 響應式設計的平衡：在不同螢幕尺寸下提供最佳體驗 前後端整合的一致性：確保編輯與顯示效果一致 核心要點回顧：\n容器設計要考慮內容需求：不要過度限制寬度，給富文本內容足夠的空間 CSS 支援要全面：涵蓋編輯器的所有排版功能，不只是圖片 測試要完整：確保編輯體驗與顯示效果在所有環境下都一致 響應式設計要平衡：在不同螢幕尺寸下都能正常工作 使用防禦性 CSS：預防潛在的排版問題 實戰建議：\n開發階段：使用寬鬆的容器寬度設定，避免過度限制 CSS 組織：將 CKEditor 樣式獨立成單獨的檔案，方便維護 版本管理：記錄 CKEditor 外掛版本，確保前後端相容性 效能優化：使用 CSS 壓縮和快取策略 監控告警：建立前端渲染監控，及早發現排版問題 希望這個完整的問題分析與解決方案能幫助到其他使用 Strapi + CKEditor + 前端框架組合的開發者。記得：編輯器的排版意圖與前端的實際顯示，應該保持一致！\n參考資源 CKEditor 5 Documentation Strapi CKEditor Plugin MDN - CSS Float MDN - CSS Box Model Vue.js v-html Directive Tailwind CSS Documentation ","permalink":"https://peter-notes.com/posts/ckeditor-image-alignment-fix/","summary":"\u003ch2 id=\"前言編輯器與前端的排版不一致之謎\"\u003e前言：編輯器與前端的排版不一致之謎\u003c/h2\u003e\n\u003cp\u003e在開發多平台醫療健康應用時，我們採用了現代化的技術棧：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e後端 CMS\u003c/strong\u003e：Strapi v5.15.1（Headless CMS）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e前端框架\u003c/strong\u003e：Vue.js 3\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e富文本編輯器\u003c/strong\u003e：CKEditor 5\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e資料傳輸\u003c/strong\u003e：GraphQL API\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e這個組合在大多數情況下運作良好，編輯者可以在 Strapi 後台使用 CKEditor 輕鬆編輯富文本內容，前端 Vue.js 應用透過 GraphQL 獲取並渲染這些內容。\u003c/p\u003e\n\u003cp\u003e然而，我們遇到了一個令人困惑的問題：\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e在 Strapi 後台使用 CKEditor 精心排版的水平並排圖片，到了前端網頁卻變成了垂直排列。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e這個問題不僅影響了內容的視覺呈現，也破壞了編輯者的排版意圖。更重要的是，這讓非技術背景的內容編輯者感到困惑：「為什麼我在後台看到的排版，到了網站上就變了？」\u003c/p\u003e\n\u003cp\u003e這篇文章將深入探討這個問題的根本原因，並提供系統性的解決方案。\u003c/p\u003e\n\u003ch2 id=\"問題現象與環境說明\"\u003e問題現象與環境說明\u003c/h2\u003e\n\u003ch3 id=\"內容流程架構\"\u003e內容流程架構\u003c/h3\u003e\n\u003cp\u003e我們的內容從編輯到展示的完整流程如下：\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/2025-08-01-解決-ckeditor-圖片水平排版在前端顯示為垂直排列的問題-diagram-7025b763.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003ch3 id=\"問題的具體表現\"\u003e問題的具體表現\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e預期行為\u003c/strong\u003e：\u003c/p\u003e\n\u003cp\u003e在 Strapi CKEditor 後台，編輯者將兩張圖片設定為水平並排：\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"CKEditor 後台預覽效果（示意）\" loading=\"lazy\" src=\"/images/ckeditor-backend-preview.png\"\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c\"\u003e\u0026lt;!-- CKEditor 生成的 HTML 結構 --\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003efigure\u003c/span\u003e \u003cspan class=\"na\"\u003eclass\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;image\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003estyle\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;float:left\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003eimg\u003c/span\u003e \u003cspan class=\"na\"\u003esrc\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;/uploads/image1.webp\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003ealt\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;圖片1\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003efigcaption\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e圖片1說明\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003efigcaption\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003efigure\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003efigure\u003c/span\u003e \u003cspan class=\"na\"\u003eclass\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;image\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003estyle\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;float:left\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003eimg\u003c/span\u003e \u003cspan class=\"na\"\u003esrc\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;/uploads/image2.webp\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003ealt\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;圖片2\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003efigcaption\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e圖片2說明\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003efigcaption\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003efigure\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e實際現象\u003c/strong\u003e：\u003c/p\u003e\n\u003cp\u003e前端 Vue.js 渲染後，圖片變成垂直排列：\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"前端實際顯示效果（問題）\" loading=\"lazy\" src=\"/images/frontend-vertical-display.png\"\u003e\u003c/p\u003e\n\u003cp\u003e兩張圖片沒有並排顯示，而是一張接著一張垂直堆疊。\u003c/p\u003e\n\u003ch3 id=\"技術環境詳情\"\u003e技術環境詳情\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eStrapi CKEditor 配置\u003c/strong\u003e（\u003ccode\u003econfig/schema.json\u003c/code\u003e）：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-json\" data-lang=\"json\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"nt\"\u003e\u0026#34;kind\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;collectionType\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"nt\"\u003e\u0026#34;collectionName\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;articles\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"nt\"\u003e\u0026#34;info\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nt\"\u003e\u0026#34;singularName\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;article\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nt\"\u003e\u0026#34;pluralName\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;articles\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nt\"\u003e\u0026#34;displayName\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;文章\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e},\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"nt\"\u003e\u0026#34;attributes\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nt\"\u003e\u0026#34;title\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"nt\"\u003e\u0026#34;type\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;string\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e},\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nt\"\u003e\u0026#34;content\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"nt\"\u003e\u0026#34;type\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;customField\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"nt\"\u003e\u0026#34;options\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nt\"\u003e\u0026#34;preset\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;defaultHtml\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"p\"\u003e},\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"nt\"\u003e\u0026#34;customField\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;plugin::ckeditor5.CKEditor\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003eVue.js 前端渲染組件\u003c/strong\u003e（\u003ccode\u003eServiceDetailView.vue\u003c/code\u003e）：\u003c/p\u003e","title":"解決 CKEditor 圖片水平排版在前端顯示為垂直排列的問題"},{"content":"引言：當精美的網站變成分享時的「無名氏」 在現代 Web 開發中，社交媒體分享功能是不可或缺的一部分。當用戶在 Facebook、LINE 或其他社交平台分享你的網站連結時，你希望顯示的是精美的預覽卡片，而不是空白或錯誤的資訊。\n然而，對於使用 Vue.js、React、Angular 等前端框架開發的單頁應用程式（SPA），這個看似簡單的需求卻隱藏著技術挑戰。\n本文將完整記錄從問題發現、原因分析、到解決方案實作的全過程。\n問題發現：為什麼分享連結總是顯示預設值？ 場景描述 我的平台 www.abc.com 是一個基於 Vue.js 3 + Strapi CMS 的網站。某天，我發現一個嚴重問題：\n當用戶分享服務頁面（如 https://www.abc.com/service-us/6）到 Facebook 或 LINE 時，顯示的預覽資訊總是預設值，而非該服務的實際標題和描述。\n實際情況對比：\n情境 期望結果 實際結果 分享服務頁面 顯示「專業網站開發服務」 顯示「ABCDEFG（預設標題）」 分享部落格文章 顯示文章標題與摘要 顯示網站預設描述 分享產品頁面 顯示產品圖片與名稱 顯示網站 Logo 診斷工具測試 使用 Facebook Open Graph Debugger 測試後發現：\n爬蟲抓取到的 HTML：\n\u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;zh-TW\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;title\u0026gt;ABCDEFG\u0026lt;/title\u0026gt; \u0026lt;meta property=\u0026#34;og:title\u0026#34; content=\u0026#34;ABCDEFG\u0026#34;\u0026gt; \u0026lt;meta property=\u0026#34;og:description\u0026#34; content=\u0026#34;預設網站描述\u0026#34;\u0026gt; \u0026lt;!-- 沒有任何動態內容！ --\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;div id=\u0026#34;app\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;script src=\u0026#34;/assets/index.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; ⚠️ 關鍵發現： 爬蟲只看到靜態的 HTML 模板，完全沒有 JavaScript 執行後動態生成的 meta 標籤。\n根本原因分析：CSR 與社交媒體爬蟲的衝突 客戶端渲染（CSR）的運作方式 CSR 流程說明：\n伺服器返回基本 HTML（只有 \u0026lt;div id=\u0026quot;app\u0026quot;\u0026gt;\u0026lt;/div\u0026gt;） 瀏覽器下載並執行 JavaScript Vue.js 啟動後，才動態生成完整內容 Meta 標籤透過 useHead() 或類似 API 動態插入 社交媒體爬蟲的運作方式 社交媒體爬蟲特性：\n爬蟲 User Agent 是否執行 JS Facebook facebookexternalhit ❌ 否 LINE LineBotMessenger ❌ 否 Twitter Twitterbot ❌ 否 WhatsApp WhatsApp ❌ 否 Google Googlebot ⚠️ 部分支援（有延遲） 核心衝突： SPA 依賴 JavaScript 動態生成內容，但社交媒體爬蟲不執行 JavaScript，因此無法看到動態 meta 標籤。\n我的技術架構 前端：\nVue.js 3 + Vite Tailwind CSS Apollo Client (GraphQL) @vueuse/head (動態 meta 管理) 後端：\nStrapi v5 (Headless CMS) GraphQL API 部署：\nKubernetes + Docker Nginx Ingress 動態 meta 設定範例（Vue.js）：\n// ServicePage.vue import { computed } from \u0026#39;vue\u0026#39; import { useHead } from \u0026#39;@vueuse/head\u0026#39; import { useQuery } from \u0026#39;@vue/apollo-composable\u0026#39; import { GET_SERVICE_PAGE } from \u0026#39;@/graphql/queries\u0026#39; export default { setup() { const { result } = useQuery(GET_SERVICE_PAGE, { pagination: { limit: 100 } }) // 動態計算 meta 標題 const metaTitle = computed(() =\u0026gt; { const openGraph = result.value?.layoutDSingles[0]?.openGraph return openGraph?.ogTitle || result.value?.title || \u0026#34;預設標題\u0026#34; }) // 動態計算 meta 描述 const metaDescription = computed(() =\u0026gt; { const openGraph = result.value?.layoutDSingles[0]?.openGraph return openGraph?.ogDescription || \u0026#34;預設描述\u0026#34; }) // 動態設定 meta 標籤 useHead(() =\u0026gt; ({ title: metaTitle.value, meta: [ { property: \u0026#39;og:title\u0026#39;, content: metaTitle.value }, { property: \u0026#39;og:description\u0026#39;, content: metaDescription.value }, { property: \u0026#39;og:image\u0026#39;, content: result.value?.openGraph?.ogImage }, { property: \u0026#39;og:url\u0026#39;, content: window.location.href } ] })) return { result, metaTitle, metaDescription } } } GraphQL 查詢範例（Strapi）：\nquery GetServicePage($pagination: PaginationArg) { layoutDSingles(pagination: $pagination) { id title content openGraph { ogTitle ogDescription ogImage } } } ⚠️ 問題： 這些動態 meta 標籤只有在瀏覽器執行 JavaScript 後才會生成，Facebook/LINE 爬蟲完全看不到。\n解決方案評估與選擇 三種主流解決方案比較 方案 1：遷移到 Nuxt.js 或其他 SSR 框架 優點：\n✅ 原生支援伺服器端渲染（SSR） ✅ SEO 友善 ✅ 完整的社交媒體支援 缺點：\n❌ 需要重寫整個 Vue.js 應用 ❌ 開發成本極高（數週甚至數月） ❌ 可能需要調整現有的 Strapi 整合 評估：\n開發時間： 4-8 週 風險： 高 建議： ❌ 不適合已有成熟應用的專案 方案 2：使用預渲染（Prerendering） 技術方案：\n使用 Vite Plugin Prerender 或 Puppeteer 建置時預先生成靜態 HTML 優點：\n✅ 實作相對簡單 ✅ 不需要額外的伺服器 缺點：\n❌ 無法處理動態內容（必須預先知道所有路由） ❌ 內容更新需要重新建置 ❌ 不適合 CMS 驅動的動態頁面 實作範例：\n// vite.config.js import { defineConfig } from \u0026#39;vite\u0026#39; import vue from \u0026#39;@vitejs/plugin-vue\u0026#39; import { VitePluginPrerender } from \u0026#39;vite-plugin-prerender\u0026#39; export default defineConfig({ plugins: [ vue(), VitePluginPrerender({ // 預渲染的路由（必須手動列出） routes: [ \u0026#39;/\u0026#39;, \u0026#39;/about\u0026#39;, \u0026#39;/service-us/1\u0026#39;, \u0026#39;/service-us/2\u0026#39;, \u0026#39;/service-us/3\u0026#39; // ❌ 如果有 100 個服務頁面，需要列出 100 個路由 // ❌ Strapi 新增內容後，需要重新建置 ] }) ] }) 評估：\n開發時間： 1-2 天 維護成本： 高（內容更新需重新部署） 建議： ⚠️ 僅適合靜態網站或路由數量少的專案 方案 3：實現專用的 Meta Server（推薦） 架構概念：\n優點：\n✅ 不需要重寫現有 Vue.js 應用 ✅ 支援完全動態的內容（從 Strapi 實時讀取） ✅ 只影響爬蟲請求（用戶體驗不變） ✅ 可以獨立部署與擴展 缺點：\n⚠️ 需要額外的 Node.js 服務 ⚠️ 需要維護 meta-server 程式碼 評估：\n開發時間： 2-3 天 維護成本： 中等 建議： ✅ 最佳解決方案 Meta Server 實作詳解 系統架構 核心程式碼實作 1. Meta Server 主程式（meta-server.js） // scripts/meta-server.js const express = require(\u0026#39;express\u0026#39;) const { ApolloClient, InMemoryCache } = require(\u0026#39;@apollo/client/core\u0026#39;) const { gql } = require(\u0026#39;@apollo/client/core\u0026#39;) const fs = require(\u0026#39;fs\u0026#39;) const path = require(\u0026#39;path\u0026#39;) const app = express() const PORT = process.env.PORT || 3001 // ============================================ // Apollo Client 設定（連接 Strapi GraphQL API） // ============================================ const client = new ApolloClient({ uri: process.env.VITE_GRAPHQL_API_URL + \u0026#39;/graphql\u0026#39;, cache: new InMemoryCache(), headers: { authorization: `Bearer ${process.env.VITE_GRAPHQL_ACCESS_TOKEN}` } }) // ============================================ // GraphQL 查詢 // ============================================ const GET_SERVICE_PAGE = gql` query GetServicePage($pagination: PaginationArg) { layoutDSingles(pagination: $pagination) { id title content openGraph { ogTitle ogDescription ogImage } } } ` // ============================================ // User Agent 檢測 // ============================================ function isCrawlerRequest(userAgent) { const crawlers = [ \u0026#39;facebookexternalhit\u0026#39;, // Facebook \u0026#39;Twitterbot\u0026#39;, // Twitter \u0026#39;LinkedInBot\u0026#39;, // LinkedIn \u0026#39;WhatsApp\u0026#39;, // WhatsApp \u0026#39;Line\u0026#39;, // LINE \u0026#39;LineBotMessenger\u0026#39;, // LINE Bot \u0026#39;Googlebot\u0026#39;, // Google \u0026#39;Baiduspider\u0026#39;, // Baidu \u0026#39;Slackbot\u0026#39; // Slack ] return crawlers.some(crawler =\u0026gt; userAgent.toLowerCase().includes(crawler.toLowerCase()) ) } // ============================================ // 讀取 HTML 模板 // ============================================ const htmlTemplatePath = path.join(__dirname, \u0026#39;../dist/index.html\u0026#39;) let htmlTemplate = fs.readFileSync(htmlTemplatePath, \u0026#39;utf-8\u0026#39;) // 在模板中插入 placeholder htmlTemplate = htmlTemplate.replace( \u0026#39;\u0026lt;/head\u0026gt;\u0026#39;, \u0026#39;\u0026lt;!-- SEO_META_PLACEHOLDER --\u0026gt;\u0026lt;/head\u0026gt;\u0026#39; ) // ============================================ // 生成 Meta 標籤 // ============================================ function generateMetaTags(data, url) { const baseUrl = process.env.VITE_BASE_URL || \u0026#39;https://www.abc.com\u0026#39; const title = data?.openGraph?.ogTitle || data?.title || \u0026#39;ABCDEFG\u0026#39; const description = data?.openGraph?.ogDescription || \u0026#39;專業服務平台\u0026#39; const image = data?.openGraph?.ogImage || `${baseUrl}/default-og-image.jpg` const fullUrl = `${baseUrl}${url}` return ` \u0026lt;!-- Open Graph Meta Tags (for Facebook, LINE, etc.) --\u0026gt; \u0026lt;meta property=\u0026#34;og:title\u0026#34; content=\u0026#34;${escapeHtml(title)}\u0026#34;\u0026gt; \u0026lt;meta property=\u0026#34;og:description\u0026#34; content=\u0026#34;${escapeHtml(description)}\u0026#34;\u0026gt; \u0026lt;meta property=\u0026#34;og:image\u0026#34; content=\u0026#34;${escapeHtml(image)}\u0026#34;\u0026gt; \u0026lt;meta property=\u0026#34;og:url\u0026#34; content=\u0026#34;${escapeHtml(fullUrl)}\u0026#34;\u0026gt; \u0026lt;meta property=\u0026#34;og:type\u0026#34; content=\u0026#34;website\u0026#34;\u0026gt; \u0026lt;!-- Twitter Card Meta Tags --\u0026gt; \u0026lt;meta name=\u0026#34;twitter:card\u0026#34; content=\u0026#34;summary_large_image\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;twitter:title\u0026#34; content=\u0026#34;${escapeHtml(title)}\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;twitter:description\u0026#34; content=\u0026#34;${escapeHtml(description)}\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;twitter:image\u0026#34; content=\u0026#34;${escapeHtml(image)}\u0026#34;\u0026gt; \u0026lt;!-- Standard Meta Tags --\u0026gt; \u0026lt;meta name=\u0026#34;description\u0026#34; content=\u0026#34;${escapeHtml(description)}\u0026#34;\u0026gt; \u0026lt;title\u0026gt;${escapeHtml(title)}\u0026lt;/title\u0026gt; ` } // HTML 轉義函數（防止 XSS） function escapeHtml(text) { const map = { \u0026#39;\u0026amp;\u0026#39;: \u0026#39;\u0026amp;amp;\u0026#39;, \u0026#39;\u0026lt;\u0026#39;: \u0026#39;\u0026amp;lt;\u0026#39;, \u0026#39;\u0026gt;\u0026#39;: \u0026#39;\u0026amp;gt;\u0026#39;, \u0026#39;\u0026#34;\u0026#39;: \u0026#39;\u0026amp;quot;\u0026#39;, \u0026#34;\u0026#39;\u0026#34;: \u0026#39;\u0026amp;#039;\u0026#39; } return String(text).replace(/[\u0026amp;\u0026lt;\u0026gt;\u0026#34;\u0026#39;]/g, m =\u0026gt; map[m]) } // ============================================ // 路由處理：服務頁面 // ============================================ app.get(\u0026#39;/service-us/:id\u0026#39;, async (req, res) =\u0026gt; { const serviceId = req.params.id const userAgent = req.get(\u0026#39;User-Agent\u0026#39;) || \u0026#39;\u0026#39; const isCrawler = isCrawlerRequest(userAgent) console.log(`[Meta Server] Request for service ${serviceId}`) console.log(`[Meta Server] User-Agent: ${userAgent}`) console.log(`[Meta Server] Is crawler: ${isCrawler}`) try { // 從 Strapi 獲取服務資料 const { data } = await client.query({ query: GET_SERVICE_PAGE, variables: { pagination: { limit: 100 } } }) const serviceData = data?.layoutDSingles?.find( item =\u0026gt; String(item.id) === String(serviceId) ) if (!serviceData) { console.log(`[Meta Server] Service ${serviceId} not found`) return res.status(404).send(\u0026#39;Service not found\u0026#39;) } // 生成 meta 標籤 const metaTags = generateMetaTags(serviceData, req.originalUrl) // 將 meta 標籤注入 HTML const html = htmlTemplate.replace( \u0026#39;\u0026lt;!-- SEO_META_PLACEHOLDER --\u0026gt;\u0026#39;, metaTags ) res.setHeader(\u0026#39;Content-Type\u0026#39;, \u0026#39;text/html; charset=utf-8\u0026#39;) res.send(html) console.log(`[Meta Server] ✅ Returned HTML with dynamic meta tags`) } catch (error) { console.error(\u0026#39;[Meta Server] Error fetching service data:\u0026#39;, error) // 失敗時返回預設 meta 標籤 const defaultMetaTags = generateMetaTags({}, req.originalUrl) const html = htmlTemplate.replace( \u0026#39;\u0026lt;!-- SEO_META_PLACEHOLDER --\u0026gt;\u0026#39;, defaultMetaTags ) res.setHeader(\u0026#39;Content-Type\u0026#39;, \u0026#39;text/html; charset=utf-8\u0026#39;) res.send(html) } }) // ============================================ // 健康檢查端點 // ============================================ app.get(\u0026#39;/_health\u0026#39;, (req, res) =\u0026gt; { res.status(200).json({ status: \u0026#39;ok\u0026#39;, service: \u0026#39;meta-server\u0026#39; }) }) // ============================================ // 啟動伺服器 // ============================================ app.listen(PORT, () =\u0026gt; { console.log(`[Meta Server] Running on port ${PORT}`) console.log(`[Meta Server] GraphQL API: ${process.env.VITE_GRAPHQL_API_URL}`) }) 2. Dockerfile 配置 # Dockerfile.meta FROM node:18-alpine AS build WORKDIR /app # 複製依賴檔案 COPY package*.json ./ # 安裝所有依賴（包括 devDependencies，因為需要 Vite） RUN npm ci # 複製原始碼 COPY . . # 建置 Vue.js 應用 RUN npm run build # ============================================ # 生產環境 # ============================================ FROM node:18-alpine AS production WORKDIR /app # 從建置階段複製檔案 COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/scripts/meta-server.js ./scripts/ COPY --from=build /app/package.json ./ # 環境變數 ENV NODE_ENV=production ENV PORT=3001 # 暴露端口 EXPOSE 3001 # 啟動 meta server CMD [\u0026#34;node\u0026#34;, \u0026#34;scripts/meta-server.js\u0026#34;] 3. Kubernetes Deployment # my_web_meta_prod_deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: my-web-meta namespace: prod labels: app: my-web-meta spec: replicas: 2 selector: matchLabels: app: my-web-meta template: metadata: labels: app: my-web-meta spec: containers: - name: my-web-meta-prod image: your-registry/my-web-meta:latest ports: - containerPort: 3001 protocol: TCP # 環境變數 env: - name: VITE_GRAPHQL_API_URL value: \u0026#34;https://api.abc.com\u0026#34; - name: VITE_GRAPHQL_ACCESS_TOKEN valueFrom: secretKeyRef: name: strapi-token key: access-token - name: VITE_BASE_URL value: \u0026#34;https://www.abc.com\u0026#34; - name: PORT value: \u0026#34;3001\u0026#34; # 資源限制 resources: requests: memory: \u0026#34;256Mi\u0026#34; cpu: \u0026#34;250m\u0026#34; limits: memory: \u0026#34;512Mi\u0026#34; cpu: \u0026#34;500m\u0026#34; # 健康檢查 livenessProbe: httpGet: path: /_health port: 3001 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /_health port: 3001 initialDelaySeconds: 10 periodSeconds: 5 --- apiVersion: v1 kind: Service metadata: name: my-web-meta namespace: prod spec: selector: app: my-web-meta ports: - protocol: TCP port: 3001 targetPort: 3001 4. Nginx Ingress 配置 # ingress.yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: my-web-ingress namespace: prod annotations: nginx.ingress.kubernetes.io/server-snippet: | # 檢測社交媒體爬蟲 set $is_crawler 0; if ($http_user_agent ~* \u0026#34;facebookexternalhit|Twitterbot|LinkedInBot|WhatsApp|Line|LineBotMessenger|Googlebot\u0026#34;) { set $is_crawler 1; } # 如果是爬蟲，轉發到 meta-server if ($is_crawler = 1) { rewrite ^(.*)$ /meta$1 last; } # SSL 設定 cert-manager.io/cluster-issuer: \u0026#34;letsencrypt-prod\u0026#34; spec: ingressClassName: nginx tls: - hosts: - www.abc.com secretName: www-abc-com-cert rules: - host: www.abc.com http: paths: # Meta server 路由（僅爬蟲） - path: /meta pathType: Prefix backend: service: name: my-web-meta port: number: 3001 # 正常 Vue.js SPA 路由（一般用戶） - path: / pathType: Prefix backend: service: name: my-web port: number: 80 部署流程與測試 部署步驟 1. 建置與推送映像 # 建置 Docker 映像 docker build -f Dockerfile.meta -t your-registry/my-web-meta:v1.0.0 . # 推送到 Registry docker push your-registry/my-web-meta:v1.0.0 # 標記為 latest docker tag your-registry/my-web-meta:v1.0.0 your-registry/my-web-meta:latest docker push your-registry/my-web-meta:latest 2. 部署到 Kubernetes # 建立 Secret（Strapi token） kubectl create secret generic strapi-token \\ --from-literal=access-token=\u0026#39;your-strapi-token\u0026#39; \\ -n prod # 部署 meta-server kubectl apply -f my_web_meta_prod_deployment.yaml # 更新 Ingress kubectl apply -f ingress.yaml # 檢查部署狀態 kubectl get pods -n prod -l app=my-web-meta kubectl logs -n prod -l app=my-web-meta --tail=50 3. 測試驗證 測試 1：健康檢查 curl http://my-web-meta.prod.svc.cluster.local:3001/_health # 預期輸出： # {\u0026#34;status\u0026#34;:\u0026#34;ok\u0026#34;,\u0026#34;service\u0026#34;:\u0026#34;meta-server\u0026#34;} 測試 2：模擬爬蟲請求 # 模擬 Facebook 爬蟲 curl -A \u0026#34;facebookexternalhit/1.1\u0026#34; \\ https://www.abc.com/service-us/6 \\ | grep \u0026#34;og:title\u0026#34; # 預期輸出應包含動態標題： # \u0026lt;meta property=\u0026#34;og:title\u0026#34; content=\u0026#34;專業網站開發服務\u0026#34;\u0026gt; 測試 3：Facebook Open Graph Debugger 訪問 https://developers.facebook.com/tools/debug/ 輸入 URL：https://www.abc.com/service-us/6 點擊「抓取新資訊」 預期結果：\n✅ og:title: 專業網站開發服務 ✅ og:description: 提供全方位的網站開發解決方案 ✅ og:image: https://www.abc.com/images/service-6.jpg 測試 4：LINE 分享測試 直接在 LINE 中分享連結，檢查預覽卡片：\n測試前：\n標題：ABCDEFG 描述：（空白） 圖片：（預設 logo） 測試後：\n✅ 標題：專業網站開發服務 ✅ 描述：提供全方位的網站開發解決方案 ✅ 圖片：服務圖片 實施過程中的挑戰與解決 問題 1：GraphQL 認證失敗 症狀：\n[Meta Server] Error fetching service data: ApolloError: Connect Timeout Error 原因：\n環境變數 VITE_GRAPHQL_ACCESS_TOKEN 未正確設定 Strapi API 拒絕未授權的請求 解決方案：\n# 1. 確認 Strapi token 正確 kubectl get secret strapi-token -n prod -o jsonpath=\u0026#39;{.data.access-token}\u0026#39; | base64 -d # 2. 檢查 Pod 環境變數 kubectl exec -n prod deployment/my-web-meta -- env | grep GRAPHQL # 3. 重新建立 Secret kubectl delete secret strapi-token -n prod kubectl create secret generic strapi-token \\ --from-literal=access-token=\u0026#39;correct-token-here\u0026#39; \\ -n prod # 4. 重啟 Deployment kubectl rollout restart deployment/my-web-meta -n prod 問題 2：Docker 建置失敗 症狀：\nsh: vite: not found ERROR: failed to build 原因：\n使用 npm ci --only=production 導致 Vite 未安裝 Vite 在 devDependencies 中，建置階段需要 解決方案：\n# ❌ 錯誤做法 RUN npm ci --only=production # ✅ 正確做法 RUN npm ci # 安裝所有依賴，包括 devDependencies 問題 3：Kubernetes 資源不足 症狀：\n0/1 nodes are available: 1 Too many pods. 原因：\nNode 上 Pod 數量達到上限 無法同時運行舊版與新版服務 解決方案（並行部署策略）：\n# 1. 先縮減舊服務副本數 kubectl scale deployment/my-web --replicas=1 -n prod # 2. 部署 meta-server kubectl apply -f my_web_meta_prod_deployment.yaml # 3. 測試 meta-server kubectl port-forward -n prod svc/my-web-meta 3001:3001 # 4. 確認無誤後，恢復舊服務副本數 kubectl scale deployment/my-web --replicas=2 -n prod 問題 4：SSL 憑證配置錯誤 症狀：\nWarning: secret \u0026#34;www-abc-com-custom-cert\u0026#34; not found 原因：\nIngress 中引用的 Secret 名稱錯誤 實際憑證名稱為 www-abc-com-cert 解決方案：\n# 1. 列出所有 TLS Secret kubectl get secrets -n prod | grep tls # 2. 修正 Ingress 配置 # ingress.yaml spec: tls: - hosts: - www.abc.com secretName: www-abc-com-cert # ✅ 修正為正確名稱 # 3. 重新套用 kubectl apply -f ingress.yaml 最佳實踐與經驗總結 1. 理解社交媒體爬蟲的特性 關鍵認知：\n特性 說明 應對策略 不執行 JS 所有動態內容不可見 ✅ 伺服器端生成 HTML 有特定 User-Agent 可識別爬蟲請求 ✅ 透過 Nginx 分流 快取機制 Facebook 會快取數小時 ✅ 使用 Debugger 強制重新抓取 超時限制 爬蟲請求會超時 ✅ 優化查詢速度 Facebook 快取更新方法：\n使用 Open Graph Debugger 點擊「抓取新資訊」按鈕 等待 Facebook 重新爬取（約 10-30 秒） 2. 部署策略建議 部署檢查清單：\n在測試環境完整驗證 確認環境變數正確設定 備份現有配置 準備回滾腳本 監控系統就緒 通知團隊部署時間 3. 監控與日誌 必要的監控指標：\n# prometheus-metrics.yaml apiVersion: v1 kind: ConfigMap metadata: name: meta-server-metrics data: metrics.js: | const prometheus = require(\u0026#39;prom-client\u0026#39;) const requestCounter = new prometheus.Counter({ name: \u0026#39;meta_server_requests_total\u0026#39;, help: \u0026#39;Total number of requests\u0026#39;, labelNames: [\u0026#39;user_agent\u0026#39;, \u0026#39;path\u0026#39;, \u0026#39;status\u0026#39;] }) const requestDuration = new prometheus.Histogram({ name: \u0026#39;meta_server_request_duration_seconds\u0026#39;, help: \u0026#39;Request duration in seconds\u0026#39;, labelNames: [\u0026#39;path\u0026#39;] }) module.exports = { requestCounter, requestDuration } 日誌記錄最佳實踐：\n// 結構化日誌 function logRequest(req, serviceId, isCrawler, success) { const logEntry = { timestamp: new Date().toISOString(), service: \u0026#39;meta-server\u0026#39;, method: req.method, path: req.path, serviceId, userAgent: req.get(\u0026#39;User-Agent\u0026#39;), isCrawler, ip: req.ip, success, responseTime: Date.now() - req.startTime } console.log(JSON.stringify(logEntry)) } 4. 錯誤處理與降級策略 // 優雅降級 app.get(\u0026#39;/service-us/:id\u0026#39;, async (req, res) =\u0026gt; { try { // 嘗試從 Strapi 獲取資料 const data = await fetchServiceData(serviceId) if (data) { return res.send(generateHTML(data)) } // 資料不存在，返回預設 meta return res.send(generateDefaultHTML()) } catch (error) { // 錯誤發生，記錄並返回預設 meta logger.error(\u0026#39;Failed to fetch service data\u0026#39;, { error, serviceId }) // ✅ 確保服務不中斷 return res.send(generateDefaultHTML()) } }) 替代方案探討 雖然我最終採用 Meta Server 方案，但也值得了解其他選項。\n方案 A：Cloudflare Workers 在 CDN 層面處理爬蟲請求：\n// cloudflare-worker.js addEventListener(\u0026#39;fetch\u0026#39;, event =\u0026gt; { event.respondWith(handleRequest(event.request)) }) async function handleRequest(request) { const userAgent = request.headers.get(\u0026#39;User-Agent\u0026#39;) || \u0026#39;\u0026#39; // 檢測爬蟲 const isCrawler = /facebookexternalhit|Twitterbot|LinkedInBot/i.test(userAgent) if (isCrawler) { // 從 Strapi API 獲取資料 const url = new URL(request.url) const serviceId = url.pathname.split(\u0026#39;/\u0026#39;).pop() const data = await fetch(`https://api.abc.com/api/services/${serviceId}`) const json = await data.json() // 返回動態生成的 HTML return new Response(generateHTML(json), { headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;text/html\u0026#39; } }) } // 正常請求，返回 SPA return fetch(request) } 優點：\n✅ 全球分布的邊緣運算 ✅ 響應速度快 ✅ 不需要額外伺服器 缺點：\n⚠️ Cloudflare Workers 有執行時間限制 ⚠️ 需要調整現有 DNS 設定 方案 B：Prerender.io 第三方服務 使用專業的預渲染服務：\n# nginx.conf location / { # 檢測爬蟲 if ($http_user_agent ~* \u0026#34;bot|crawler|spider|facebookexternalhit\u0026#34;) { proxy_pass https://service.prerender.io/https://www.abc.com$request_uri; } # 正常用戶 proxy_pass http://vue-spa-backend; } 優點：\n✅ 零維護成本 ✅ 支援所有爬蟲 ✅ 專業團隊維護 缺點：\n⚠️ 月費約 $10-200（依流量） ⚠️ 依賴第三方服務 結論與下一步 關鍵收穫 ✅ 技術選型的重要性\n在專案初期就應該考慮 SEO 和社交媒體分享需求 SPA 雖然開發體驗好，但在 SEO 方面有天然劣勢 ✅ 漸進式改進優於重寫\n不需要從 Vue.js 遷移到 Nuxt.js 透過添加 Meta Server 解決特定問題 ✅ 充分的測試與監控\n使用 Facebook Debugger 驗證 實際在 LINE/Facebook 中測試 建立完整的日誌與監控 ✅ 部署安全第一\n並行部署策略 快速回滾機制 分階段切換流量 完整架構總結 後續改進方向 效能優化\n加入 Redis 快取（減少 Strapi 查詢） 實作 CDN 快取策略 壓縮 HTML 輸出 功能擴展\n支援更多路由類型（部落格、產品頁） 加入結構化資料（JSON-LD） 支援多語言 meta 標籤 監控增強\n整合 Prometheus + Grafana 設定 Slack 告警 建立效能儀表板 最後的建議 給 SPA 開發者的建議：\n如果是新專案：\n考慮使用 Nuxt.js、Next.js 等 SSR 框架 從一開始就規劃 SEO 策略 如果是既有專案：\n評估 Meta Server 方案（本文介紹） 或考慮 Cloudflare Workers 等邊緣運算方案 無論哪種方案：\n務必測試 Facebook Debugger 在實際社交平台驗證 建立完整的監控機制 記住，技術問題總有解決方案，關鍵是要理解問題的本質，選擇合適的方法，並在實施過程中保持謹慎和耐心。\n參考資源 Open Graph Protocol 官方文件 Facebook Sharing Debugger Vueuse Head 文件 Strapi GraphQL API Nginx Ingress Controller Express.js 官方文件 Apollo Client 文件 ","permalink":"https://peter-notes.com/posts/vue-spa-opengraph-facebook-line/","summary":"\u003ch2 id=\"引言當精美的網站變成分享時的無名氏\"\u003e引言：當精美的網站變成分享時的「無名氏」\u003c/h2\u003e\n\u003cp\u003e在現代 Web 開發中，社交媒體分享功能是不可或缺的一部分。當用戶在 Facebook、LINE 或其他社交平台分享你的網站連結時，你希望顯示的是精美的預覽卡片，而不是空白或錯誤的資訊。\u003c/p\u003e\n\u003cp\u003e然而，對於使用 \u003cstrong\u003eVue.js、React、Angular\u003c/strong\u003e 等前端框架開發的\u003cstrong\u003e單頁應用程式（SPA）\u003c/strong\u003e，這個看似簡單的需求卻隱藏著技術挑戰。\u003c/p\u003e\n\u003cp\u003e本文將完整記錄從問題發現、原因分析、到解決方案實作的全過程。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"問題發現為什麼分享連結總是顯示預設值\"\u003e問題發現：為什麼分享連結總是顯示預設值？\u003c/h2\u003e\n\u003ch3 id=\"場景描述\"\u003e場景描述\u003c/h3\u003e\n\u003cp\u003e我的平台 \u003ca href=\"https://www.abc.com\"\u003ewww.abc.com\u003c/a\u003e 是一個基於 \u003cstrong\u003eVue.js 3 + Strapi CMS\u003c/strong\u003e 的網站。某天，我發現一個嚴重問題：\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e當用戶分享服務頁面（如 \u003ccode\u003ehttps://www.abc.com/service-us/6\u003c/code\u003e）到 Facebook 或 LINE 時，顯示的預覽資訊總是預設值，而非該服務的實際標題和描述。\u003c/strong\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003e實際情況對比：\u003c/strong\u003e\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e情境\u003c/th\u003e\n          \u003cth\u003e期望結果\u003c/th\u003e\n          \u003cth\u003e實際結果\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e分享服務頁面\u003c/td\u003e\n          \u003ctd\u003e顯示「專業網站開發服務」\u003c/td\u003e\n          \u003ctd\u003e顯示「ABCDEFG（預設標題）」\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e分享部落格文章\u003c/td\u003e\n          \u003ctd\u003e顯示文章標題與摘要\u003c/td\u003e\n          \u003ctd\u003e顯示網站預設描述\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e分享產品頁面\u003c/td\u003e\n          \u003ctd\u003e顯示產品圖片與名稱\u003c/td\u003e\n          \u003ctd\u003e顯示網站 Logo\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch3 id=\"診斷工具測試\"\u003e診斷工具測試\u003c/h3\u003e\n\u003cp\u003e使用 \u003ca href=\"https://developers.facebook.com/tools/debug/\"\u003eFacebook Open Graph Debugger\u003c/a\u003e 測試後發現：\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e爬蟲抓取到的 HTML：\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"cp\"\u003e\u0026lt;!DOCTYPE html\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ehtml\u003c/span\u003e \u003cspan class=\"na\"\u003elang\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;zh-TW\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ehead\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003emeta\u003c/span\u003e \u003cspan class=\"na\"\u003echarset\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;UTF-8\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003etitle\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003eABCDEFG\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003etitle\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003emeta\u003c/span\u003e \u003cspan class=\"na\"\u003eproperty\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;og:title\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003econtent\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;ABCDEFG\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003emeta\u003c/span\u003e \u003cspan class=\"na\"\u003eproperty\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;og:description\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003econtent\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;預設網站描述\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"c\"\u003e\u0026lt;!-- 沒有任何動態內容！ --\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ehead\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ebody\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ediv\u003c/span\u003e \u003cspan class=\"na\"\u003eid\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;app\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ediv\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003escript\u003c/span\u003e \u003cspan class=\"na\"\u003esrc\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;/assets/index.js\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003escript\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ebody\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ehtml\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cblockquote\u003e\n\u003cp\u003e⚠️ \u003cstrong\u003e關鍵發現：\u003c/strong\u003e 爬蟲只看到靜態的 HTML 模板，完全沒有 JavaScript 執行後動態生成的 meta 標籤。\u003c/p\u003e","title":"Vue.js SPA 社交分享完整指南：解決 Facebook/LINE OpenGraph 爬蟲問題"},{"content":"前言：那個令人抓狂的錯誤訊息 在開發前後端分離的 Web 應用時，幾乎每位工程師都曾遇過這個令人頭痛的錯誤：\nAccess to fetch at \u0026#39;http://localhost:1337/api/auth/local\u0026#39; from origin \u0026#39;http://localhost:5173\u0026#39; has been blocked by CORS policy: No \u0026#39;Access-Control-Allow-Origin\u0026#39; header is present on the requested resource. 這個錯誤通常發生在最關鍵的時刻：\n前端登入功能即將完成，卻無法呼叫後端 API 串接第三方服務時，資料無法正常取得 部署到測試環境後，原本運作正常的功能突然失效 CORS（Cross-Origin Resource Sharing，跨來源資源共用）是現代 Web 開發中的核心安全機制，但也是許多開發者的痛點。這篇文章將從基礎原理到實戰應用，帶你完整理解 CORS 的運作方式，並提供實際可用的解決方案。\n為什麼需要 CORS？從同源政策說起 同源政策的誕生 在理解 CORS 之前，我們需要先認識「同源政策」（Same-Origin Policy, SOP）。這是瀏覽器最基本的安全機制，在 1995 年 Netscape Navigator 2.0 引入 JavaScript 時就已經存在。\n同源政策的目的：防止惡意網站讀取另一個網站的敏感資料。\n想像一個情境：你登入了網路銀行（https://bank.com），此時你的瀏覽器保存了銀行的登入 Cookie。如果沒有同源政策，當你不小心訪問了一個惡意網站（https://evil.com），該網站的 JavaScript 就能透過你的瀏覽器向 https://bank.com 發送請求，並讀取你的帳戶資料。\n同源政策阻止了這種攻擊：https://evil.com 的 JavaScript 無法讀取 https://bank.com 的回應內容。\n什麼是「同源」？ 兩個 URL 被視為同源，必須滿足以下三個條件：\n協定（Protocol）相同：http vs https 網域（Domain）相同：example.com vs api.example.com 埠號（Port）相同：80 vs 8080 只要有任何一項不同，就視為「跨來源」（Cross-Origin）。\n實際範例：\n假設當前頁面是 https://www.example.com/page.html\nURL 是否同源 原因 https://www.example.com/api/data ✅ 同源 協定、網域、埠號都相同 http://www.example.com/api/data ❌ 跨源 協定不同（http vs https） https://api.example.com/data ❌ 跨源 網域不同（www vs api） https://www.example.com:8080/data ❌ 跨源 埠號不同（443 vs 8080） https://www.evil.com/steal ❌ 跨源 網域完全不同 前後端分離架構的挑戰 在傳統的 Monolithic 架構中，前端和後端都部署在同一個網域下，不會遇到 CORS 問題。但在現代的前後端分離架構中：\n前端：https://app.example.com（靜態檔案，部署在 CDN） 後端 API：https://api.example.com（動態服務，部署在應用伺服器） 這兩者屬於不同的子網域，所有 API 請求都是跨來源請求。這就是為什麼我們需要 CORS。\nCORS 的運作原理 CORS 是一個 W3C 標準，允許伺服器明確聲明「哪些來源可以存取我的資源」。它透過一系列 HTTP Headers 來實現這個機制。\n簡單請求 vs 預檢請求 CORS 將跨來源請求分為兩類：\n簡單請求（Simple Request）：直接發送實際請求 預檢請求（Preflight Request）：先發送 OPTIONS 請求確認權限，再發送實際請求 簡單請求的條件 請求必須同時滿足以下所有條件，才會被視為簡單請求：\n1. HTTP 方法限制：只能是以下三種之一\nGET POST HEAD 2. Headers 限制：除了瀏覽器自動設定的 Headers（如 User-Agent），只能包含以下 Headers：\nAccept Accept-Language Content-Language Content-Type（但僅限以下三種值） application/x-www-form-urlencoded multipart/form-data text/plain 3. 不能使用自訂 Headers：如 Authorization、X-Custom-Header 等\n4. 不能註冊 XMLHttpRequest 的事件監聽器\n實際案例分析：\n// ✅ 簡單請求範例 fetch(\u0026#39;https://api.example.com/data\u0026#39;, { method: \u0026#39;GET\u0026#39;, headers: { \u0026#39;Accept\u0026#39;: \u0026#39;application/json\u0026#39; } }); // ❌ 預檢請求範例（因為使用了 Authorization header） fetch(\u0026#39;https://api.example.com/data\u0026#39;, { method: \u0026#39;GET\u0026#39;, headers: { \u0026#39;Accept\u0026#39;: \u0026#39;application/json\u0026#39;, \u0026#39;Authorization\u0026#39;: \u0026#39;Bearer token123\u0026#39; // 觸發預檢 } }); // ❌ 預檢請求範例（因為 Content-Type 不在允許清單） fetch(\u0026#39;https://api.example.com/data\u0026#39;, { method: \u0026#39;POST\u0026#39;, headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; // 觸發預檢 }, body: JSON.stringify({ name: \u0026#39;Peter\u0026#39; }) }); 預檢請求的完整流程 當請求不符合簡單請求的條件時，瀏覽器會先發送一個 OPTIONS 請求，詢問伺服器是否允許這個跨來源請求。\n預檢請求的關鍵 Headers：\nHeader 方向 用途 範例 Origin 請求 告知伺服器請求來自哪個來源 https://app.example.com Access-Control-Request-Method 請求 告知實際請求會用什麼 HTTP 方法 POST Access-Control-Request-Headers 請求 告知實際請求會帶哪些自訂 Headers content-type, authorization Access-Control-Allow-Origin 回應 允許的來源（單一值或 *） https://app.example.com Access-Control-Allow-Methods 回應 允許的 HTTP 方法 GET, POST, PUT, DELETE Access-Control-Allow-Headers 回應 允許的自訂 Headers Content-Type, Authorization Access-Control-Max-Age 回應 預檢結果的快取時間（秒） 86400（24 小時） Credentials 與 Cookie 傳遞 預設情況下，跨來源請求不會傳送 Cookie、HTTP Authentication 或 TLS 客戶端憑證。\n如果你需要在跨來源請求中傳送 Cookie（例如保持登入狀態），需要同時設定前端和後端：\n前端設定：\nfetch(\u0026#39;https://api.example.com/user/profile\u0026#39;, { method: \u0026#39;GET\u0026#39;, credentials: \u0026#39;include\u0026#39; // 傳送 Cookie }); 後端設定：\n// 伺服器必須回應 Access-Control-Allow-Credentials: true Access-Control-Allow-Origin: https://app.example.com // 不能是 * 重要限制：當 credentials: true 時，Access-Control-Allow-Origin 不能設為 *，必須明確指定來源。\n常見的 CORS 錯誤場景與解決方案 錯誤情境一：Multiple Origins 格式錯誤 這是最常見的錯誤之一，通常發生在使用環境變數設定 CORS 時。\n錯誤設定：\n# .env CORS_URLS=https://app.example.com,http://localhost:5173,http://localhost:1337 錯誤的後端實作（直接將字串傳給 header）：\n// ❌ 錯誤：將逗號分隔的字串直接傳給 header app.use((req, res, next) =\u0026gt; { res.setHeader(\u0026#39;Access-Control-Allow-Origin\u0026#39;, process.env.CORS_URLS); next(); }); 實際回應：\nAccess-Control-Allow-Origin: https://app.example.com,http://localhost:5173,http://localhost:1337 問題：Access-Control-Allow-Origin 只能是單一值或 *，不能是逗號分隔的清單。\n瀏覽器錯誤訊息：\nThe \u0026#39;Access-Control-Allow-Origin\u0026#39; header contains multiple values \u0026#39;https://app.example.com,http://localhost:5173,http://localhost:1337\u0026#39;, but only one is allowed. 正確解法：動態判斷請求的 Origin，只回應符合的單一值\n// ✅ 正確：動態回應單一 Origin const allowedOrigins = process.env.CORS_URLS?.split(\u0026#39;,\u0026#39;) || []; app.use((req, res, next) =\u0026gt; { const origin = req.headers.origin; if (allowedOrigins.includes(origin)) { res.setHeader(\u0026#39;Access-Control-Allow-Origin\u0026#39;, origin); res.setHeader(\u0026#39;Access-Control-Allow-Credentials\u0026#39;, \u0026#39;true\u0026#39;); } next(); }); 錯誤情境二：遺漏 Credentials Header 當前端設定 credentials: 'include' 但後端沒有回應對應的 header。\n前端程式碼：\nfetch(\u0026#39;https://api.example.com/api/auth/local\u0026#39;, { method: \u0026#39;POST\u0026#39;, credentials: \u0026#39;include\u0026#39;, // 要傳送 Cookie headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; }, body: JSON.stringify({ identifier: \u0026#39;user@example.com\u0026#39;, password: \u0026#39;password123\u0026#39; }) }); 後端錯誤設定：\n// ❌ 只設定了 Allow-Origin，沒有 Allow-Credentials res.setHeader(\u0026#39;Access-Control-Allow-Origin\u0026#39;, \u0026#39;https://app.example.com\u0026#39;); 瀏覽器錯誤訊息：\nThe value of the \u0026#39;Access-Control-Allow-Credentials\u0026#39; header in the response is \u0026#39;\u0026#39; which must be \u0026#39;true\u0026#39; when the request\u0026#39;s credentials mode is \u0026#39;include\u0026#39;. 正確解法：\n// ✅ 必須同時設定兩個 headers res.setHeader(\u0026#39;Access-Control-Allow-Origin\u0026#39;, \u0026#39;https://app.example.com\u0026#39;); res.setHeader(\u0026#39;Access-Control-Allow-Credentials\u0026#39;, \u0026#39;true\u0026#39;); 錯誤情境三：預檢請求回應錯誤 OPTIONS 請求沒有正確處理，導致實際請求無法發送。\n錯誤實作：\n// ❌ OPTIONS 請求沒有回應 CORS headers app.options(\u0026#39;/api/login\u0026#39;, (req, res) =\u0026gt; { res.status(200).end(); // 空回應 }); 瀏覽器錯誤訊息：\nResponse to preflight request doesn\u0026#39;t pass access control check: No \u0026#39;Access-Control-Allow-Origin\u0026#39; header is present on the requested resource. 正確解法：\n// ✅ OPTIONS 請求必須回應完整的 CORS headers app.options(\u0026#39;/api/login\u0026#39;, (req, res) =\u0026gt; { res.setHeader(\u0026#39;Access-Control-Allow-Origin\u0026#39;, \u0026#39;https://app.example.com\u0026#39;); res.setHeader(\u0026#39;Access-Control-Allow-Methods\u0026#39;, \u0026#39;POST, GET, OPTIONS\u0026#39;); res.setHeader(\u0026#39;Access-Control-Allow-Headers\u0026#39;, \u0026#39;Content-Type, Authorization\u0026#39;); res.setHeader(\u0026#39;Access-Control-Max-Age\u0026#39;, \u0026#39;86400\u0026#39;); // 快取 24 小時 res.status(204).end(); // 204 No Content }); 錯誤情境四：使用萬用字元與 Credentials 衝突 這是安全性相關的限制，容易被忽略。\n錯誤設定：\n// ❌ 不能同時使用 * 和 credentials: true res.setHeader(\u0026#39;Access-Control-Allow-Origin\u0026#39;, \u0026#39;*\u0026#39;); res.setHeader(\u0026#39;Access-Control-Allow-Credentials\u0026#39;, \u0026#39;true\u0026#39;); 瀏覽器錯誤訊息：\nThe value of the \u0026#39;Access-Control-Allow-Origin\u0026#39; header in the response must not be the wildcard \u0026#39;*\u0026#39; when the request\u0026#39;s credentials mode is \u0026#39;include\u0026#39;. 正確解法：\n// ✅ 明確指定 Origin res.setHeader(\u0026#39;Access-Control-Allow-Origin\u0026#39;, req.headers.origin); res.setHeader(\u0026#39;Access-Control-Allow-Credentials\u0026#39;, \u0026#39;true\u0026#39;); 實戰設定指南 Strapi CMS 的 CORS 設定 Strapi 是一個熱門的 Headless CMS，在前後端分離架構中經常遇到 CORS 問題。\n完整設定範例（config/middlewares.js）：\nmodule.exports = [ \u0026#39;strapi::errors\u0026#39;, \u0026#39;strapi::security\u0026#39;, { name: \u0026#39;strapi::cors\u0026#39;, config: { // 允許的來源清單 origin: [ \u0026#39;http://localhost:5173\u0026#39;, // Vite 開發環境 \u0026#39;http://localhost:3000\u0026#39;, // Next.js 開發環境 \u0026#39;https://app.example.com\u0026#39;, // 生產環境前端 \u0026#39;https://staging.example.com\u0026#39; // 測試環境前端 ], // 允許的 HTTP 方法 methods: [\u0026#39;GET\u0026#39;, \u0026#39;POST\u0026#39;, \u0026#39;PUT\u0026#39;, \u0026#39;PATCH\u0026#39;, \u0026#39;DELETE\u0026#39;, \u0026#39;OPTIONS\u0026#39;], // 允許的 Headers headers: [ \u0026#39;Content-Type\u0026#39;, \u0026#39;Authorization\u0026#39;, \u0026#39;X-Frame-Options\u0026#39; ], // 允許傳送 Credentials（Cookie） credentials: true, // 暴露給前端的 Headers exposedHeaders: [\u0026#39;X-Total-Count\u0026#39;], // 預檢請求快取時間（秒） maxAge: 86400 }, }, \u0026#39;strapi::poweredBy\u0026#39;, \u0026#39;strapi::logger\u0026#39;, \u0026#39;strapi::query\u0026#39;, \u0026#39;strapi::body\u0026#39;, \u0026#39;strapi::session\u0026#39;, \u0026#39;strapi::favicon\u0026#39;, \u0026#39;strapi::public\u0026#39;, ]; 使用環境變數動態設定：\n// config/middlewares.js module.exports = ({ env }) =\u0026gt; { // 從環境變數讀取並轉換為陣列 const corsOrigins = env(\u0026#39;CORS_URLS\u0026#39;, \u0026#39;http://localhost:5173\u0026#39;).split(\u0026#39;,\u0026#39;); return [ \u0026#39;strapi::errors\u0026#39;, \u0026#39;strapi::security\u0026#39;, { name: \u0026#39;strapi::cors\u0026#39;, config: { origin: corsOrigins, credentials: true, }, }, // ... 其他 middlewares ]; }; # .env CORS_URLS=https://app.example.com,https://admin.example.com,http://localhost:5173 Express.js 的 CORS 設定 Express 是最流行的 Node.js Web 框架，可使用 cors 套件快速設定。\n安裝套件：\nnpm install cors 基本設定：\nconst express = require(\u0026#39;express\u0026#39;); const cors = require(\u0026#39;cors\u0026#39;); const app = express(); // ✅ 方法一：允許所有來源（僅適用於公開 API） app.use(cors()); // ✅ 方法二：設定允許的來源清單 app.use(cors({ origin: [ \u0026#39;https://app.example.com\u0026#39;, \u0026#39;http://localhost:3000\u0026#39; ], credentials: true // 允許 Cookie })); // ✅ 方法三：動態驗證來源 const allowedOrigins = [ \u0026#39;https://app.example.com\u0026#39;, \u0026#39;http://localhost:3000\u0026#39;, /\\.example\\.com$/ // 支援正規表達式 ]; app.use(cors({ origin: function (origin, callback) { // 允許沒有 origin 的請求（例如 Postman、curl） if (!origin) return callback(null, true); // 檢查是否在允許清單 const isAllowed = allowedOrigins.some(allowed =\u0026gt; { if (allowed instanceof RegExp) { return allowed.test(origin); } return allowed === origin; }); if (isAllowed) { callback(null, true); } else { callback(new Error(\u0026#39;Not allowed by CORS\u0026#39;)); } }, credentials: true })); 針對特定路由設定：\n// 公開 API：允許所有來源 app.get(\u0026#39;/api/public/data\u0026#39;, cors(), (req, res) =\u0026gt; { res.json({ message: \u0026#39;Public data\u0026#39; }); }); // 私有 API：僅允許特定來源 const privateCors = cors({ origin: \u0026#39;https://app.example.com\u0026#39;, credentials: true }); app.post(\u0026#39;/api/private/user\u0026#39;, privateCors, (req, res) =\u0026gt; { res.json({ message: \u0026#39;Private data\u0026#39; }); }); Nginx 反向代理的 CORS 設定 在生產環境中，通常會使用 Nginx 作為反向代理，可以在 Nginx 層級處理 CORS。\n基本設定（/etc/nginx/sites-available/api.example.com）：\nserver { listen 443 ssl http2; server_name api.example.com; # SSL 憑證設定 ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; location / { # 反向代理到後端服務 proxy_pass http://localhost:3000; # 設定 CORS Headers add_header \u0026#39;Access-Control-Allow-Origin\u0026#39; \u0026#39;https://app.example.com\u0026#39; always; add_header \u0026#39;Access-Control-Allow-Methods\u0026#39; \u0026#39;GET, POST, PUT, DELETE, OPTIONS\u0026#39; always; add_header \u0026#39;Access-Control-Allow-Headers\u0026#39; \u0026#39;Content-Type, Authorization\u0026#39; always; add_header \u0026#39;Access-Control-Allow-Credentials\u0026#39; \u0026#39;true\u0026#39; always; # 處理預檢請求 if ($request_method = \u0026#39;OPTIONS\u0026#39;) { add_header \u0026#39;Access-Control-Allow-Origin\u0026#39; \u0026#39;https://app.example.com\u0026#39; always; add_header \u0026#39;Access-Control-Allow-Methods\u0026#39; \u0026#39;GET, POST, PUT, DELETE, OPTIONS\u0026#39; always; add_header \u0026#39;Access-Control-Allow-Headers\u0026#39; \u0026#39;Content-Type, Authorization\u0026#39; always; add_header \u0026#39;Access-Control-Max-Age\u0026#39; 86400 always; add_header \u0026#39;Content-Length\u0026#39; 0; return 204; } } } 動態處理多個來源：\n# 使用 map 指令動態設定 Origin map $http_origin $cors_origin { default \u0026#34;\u0026#34;; \u0026#34;https://app.example.com\u0026#34; $http_origin; \u0026#34;https://admin.example.com\u0026#34; $http_origin; \u0026#34;~^https://.*\\.example\\.com$\u0026#34; $http_origin; # 正規表達式 } server { listen 443 ssl http2; server_name api.example.com; location / { proxy_pass http://localhost:3000; # 僅當 Origin 符合時才回應 CORS headers add_header \u0026#39;Access-Control-Allow-Origin\u0026#39; $cors_origin always; add_header \u0026#39;Access-Control-Allow-Credentials\u0026#39; \u0026#39;true\u0026#39; always; if ($request_method = \u0026#39;OPTIONS\u0026#39;) { add_header \u0026#39;Access-Control-Allow-Origin\u0026#39; $cors_origin always; add_header \u0026#39;Access-Control-Allow-Methods\u0026#39; \u0026#39;GET, POST, PUT, DELETE, OPTIONS\u0026#39; always; add_header \u0026#39;Access-Control-Allow-Headers\u0026#39; \u0026#39;Content-Type, Authorization\u0026#39; always; add_header \u0026#39;Access-Control-Max-Age\u0026#39; 86400 always; return 204; } } } Next.js 的 CORS 設定 Next.js API Routes 需要手動處理 CORS。\n方法一：使用 next-cors 套件：\nnpm install next-cors // pages/api/data.js import NextCors from \u0026#39;next-cors\u0026#39;; export default async function handler(req, res) { // 執行 CORS middleware await NextCors(req, res, { origin: [\u0026#39;https://app.example.com\u0026#39;, \u0026#39;http://localhost:3000\u0026#39;], methods: [\u0026#39;GET\u0026#39;, \u0026#39;POST\u0026#39;, \u0026#39;PUT\u0026#39;, \u0026#39;DELETE\u0026#39;, \u0026#39;OPTIONS\u0026#39;], credentials: true, }); // 你的 API 邏輯 res.json({ message: \u0026#39;Hello from Next.js API\u0026#39; }); } 方法二：手動實作 CORS middleware：\n// lib/cors.js const allowedOrigins = [ \u0026#39;https://app.example.com\u0026#39;, \u0026#39;http://localhost:3000\u0026#39; ]; export function cors(req, res) { const origin = req.headers.origin; if (allowedOrigins.includes(origin)) { res.setHeader(\u0026#39;Access-Control-Allow-Origin\u0026#39;, origin); } res.setHeader(\u0026#39;Access-Control-Allow-Credentials\u0026#39;, \u0026#39;true\u0026#39;); res.setHeader(\u0026#39;Access-Control-Allow-Methods\u0026#39;, \u0026#39;GET, POST, PUT, DELETE, OPTIONS\u0026#39;); res.setHeader(\u0026#39;Access-Control-Allow-Headers\u0026#39;, \u0026#39;Content-Type, Authorization\u0026#39;); // 處理預檢請求 if (req.method === \u0026#39;OPTIONS\u0026#39;) { res.setHeader(\u0026#39;Access-Control-Max-Age\u0026#39;, \u0026#39;86400\u0026#39;); res.status(204).end(); return true; // 表示已經處理完畢 } return false; } // pages/api/login.js import { cors } from \u0026#39;../../lib/cors\u0026#39;; export default function handler(req, res) { // 執行 CORS 檢查 if (cors(req, res)) return; // 預檢請求已處理 // 你的 API 邏輯 if (req.method === \u0026#39;POST\u0026#39;) { // 處理登入 res.json({ token: \u0026#39;abc123\u0026#39; }); } else { res.status(405).json({ error: \u0026#39;Method not allowed\u0026#39; }); } } CORS 錯誤排查流程 當遇到 CORS 錯誤時，可以按照以下流程進行排查。\n使用 DevTools 檢查 CORS 步驟一：開啟 Network 分頁\n按 F12 開啟開發者工具 切換到「Network」分頁 重新載入頁面或觸發 API 請求 步驟二：找到失敗的請求\n失敗的跨來源請求會顯示為紅色 檢查請求的 Status Code（通常是 CORS 錯誤） 步驟三：檢查 Headers\n點擊請求，查看「Headers」分頁：\n請求 Headers（Request Headers）：\nOrigin: https://app.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type, authorization 回應 Headers（Response Headers）：\nAccess-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Allow-Credentials: true 步驟四：檢查 Console 的錯誤訊息\nConsole 會顯示具體的 CORS 錯誤原因：\nAccess to fetch at \u0026#39;https://api.example.com/login\u0026#39; from origin \u0026#39;https://app.example.com\u0026#39; has been blocked by CORS policy: Response to preflight request doesn\u0026#39;t pass access control check: The value of the \u0026#39;Access-Control-Allow-Credentials\u0026#39; header in the response is \u0026#39;\u0026#39; which must be \u0026#39;true\u0026#39; when the request\u0026#39;s credentials mode is \u0026#39;include\u0026#39;. 常見錯誤訊息解讀 錯誤訊息 原因 解決方法 No 'Access-Control-Allow-Origin' header is present 後端沒有設定 CORS 加入 Access-Control-Allow-Origin header The 'Access-Control-Allow-Origin' header contains multiple values Origin header 格式錯誤 改為回應單一 Origin 值 The 'Access-Control-Allow-Origin' header has a value '...' that is not equal to the supplied origin Origin 不符合 將請求的 Origin 加入允許清單 Credentials flag is 'true', but 'Access-Control-Allow-Credentials' header is '' 使用 credentials 但後端未設定 加入 Access-Control-Allow-Credentials: true The value of the 'Access-Control-Allow-Origin' header must not be '*' when credentials mode is 'include' Credentials 與萬用字元衝突 改為明確指定 Origin Method ... is not allowed by Access-Control-Allow-Methods HTTP 方法不在允許清單 在 Access-Control-Allow-Methods 加入該方法 Request header field ... is not allowed by Access-Control-Allow-Headers 自訂 header 不在允許清單 在 Access-Control-Allow-Headers 加入該 header 進階主題 Vary Header 的重要性 當使用動態 CORS（根據請求的 Origin 回應不同值）時，必須加入 Vary: Origin header。\n為什麼需要 Vary？\n假設你的 API 允許兩個來源：\nhttps://app.example.com https://admin.example.com 如果沒有 Vary: Origin，CDN 或瀏覽器快取可能會發生以下問題：\nhttps://app.example.com 請求 → 伺服器回應 Access-Control-Allow-Origin: https://app.example.com 快取儲存這個回應 https://admin.example.com 請求 → 直接從快取回傳，仍然是 Access-Control-Allow-Origin: https://app.example.com CORS 錯誤！因為 Origin 不符 正確設定：\napp.use((req, res, next) =\u0026gt; { const origin = req.headers.origin; if (allowedOrigins.includes(origin)) { res.setHeader(\u0026#39;Access-Control-Allow-Origin\u0026#39;, origin); res.setHeader(\u0026#39;Vary\u0026#39;, \u0026#39;Origin\u0026#39;); // 關鍵！ } next(); }); Vary: Origin 告訴快取系統：「這個回應會根據 Origin header 而不同，請分別快取」。\nOPTIONS 請求的快取 預檢請求（OPTIONS）可以透過 Access-Control-Max-Age 進行快取，減少不必要的網路請求。\n設定範例：\nres.setHeader(\u0026#39;Access-Control-Max-Age\u0026#39;, \u0026#39;86400\u0026#39;); // 24 小時 注意事項：\nChrome 的最大值為 600（10 分鐘）（舊版本，新版已支援更長） Firefox 最大值為 86400（24 小時） 不同瀏覽器的實作可能不同 建議值：\n開發環境：600（10 分鐘） 生產環境：86400（24 小時） CORS 與 CSRF 的關係 CORS 和 CSRF（Cross-Site Request Forgery，跨站請求偽造）是兩個不同但相關的安全概念。\nCORS 無法防禦 CSRF：\n即使正確設定 CORS，惡意網站仍然可以發送「簡單請求」（例如表單提交），因為這些請求不會被預檢。\nCSRF 攻擊範例：\n\u0026lt;!-- 惡意網站的 HTML --\u0026gt; \u0026lt;form action=\u0026#34;https://bank.com/transfer\u0026#34; method=\u0026#34;POST\u0026#34;\u0026gt; \u0026lt;input type=\u0026#34;hidden\u0026#34; name=\u0026#34;to\u0026#34; value=\u0026#34;attacker\u0026#34;\u0026gt; \u0026lt;input type=\u0026#34;hidden\u0026#34; name=\u0026#34;amount\u0026#34; value=\u0026#34;1000\u0026#34;\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;script\u0026gt; document.forms[0].submit(); \u0026lt;/script\u0026gt; 如果使用者已經登入銀行網站，這個請求會帶著 Cookie 發送，可能成功執行轉帳。\nCORS 的限制：\nCORS 只阻止讀取回應，不阻止發送請求 簡單請求（如表單 POST）不會觸發預檢 防禦 CSRF 的方法：\nCSRF Token：在表單中加入隨機 token SameSite Cookie：設定 SameSite=Strict 或 SameSite=Lax 檢查 Referer/Origin Header：驗證請求來源 要求自訂 Header：強制觸發預檢（例如 X-Requested-With: XMLHttpRequest） 生產環境的 CORS 最佳實踐 1. 明確指定允許的來源\n// ❌ 避免在生產環境使用 origin: \u0026#39;*\u0026#39; // ✅ 明確列出允許的來源 origin: [ \u0026#39;https://app.example.com\u0026#39;, \u0026#39;https://admin.example.com\u0026#39; ] 2. 使用環境變數管理來源清單\n// .env.production CORS_URLS=https://app.example.com,https://admin.example.com // .env.development CORS_URLS=http://localhost:3000,http://localhost:5173 3. 記錄被拒絕的請求\napp.use((req, res, next) =\u0026gt; { const origin = req.headers.origin; if (allowedOrigins.includes(origin)) { res.setHeader(\u0026#39;Access-Control-Allow-Origin\u0026#39;, origin); } else if (origin) { // 記錄未授權的請求 console.warn(`Blocked CORS request from: ${origin}`); } next(); }); 4. 針對不同 API 設定不同的 CORS 政策\n// 公開 API：允許所有來源 app.get(\u0026#39;/api/public/*\u0026#39;, cors({ origin: \u0026#39;*\u0026#39; }), publicHandler); // 私有 API：僅允許特定來源 app.use(\u0026#39;/api/private/*\u0026#39;, cors({ origin: privateOrigins, credentials: true }), privateHandler); 5. 設定適當的快取時間\n// 開發環境：短時間快取，方便測試 const maxAge = process.env.NODE_ENV === \u0026#39;production\u0026#39; ? 86400 : 300; res.setHeader(\u0026#39;Access-Control-Max-Age\u0026#39;, maxAge); 結語 CORS 是現代 Web 開發中不可避免的主題，雖然初次接觸時可能覺得複雜，但理解其運作原理後，就能快速定位問題並找到解決方案。\n核心要點回顧：\n同源政策是基礎：理解什麼是「同源」，為什麼需要 CORS 簡單請求 vs 預檢請求：知道什麼時候會觸發 OPTIONS 請求 Credentials 的限制：使用 Cookie 時不能用萬用字元 * 動態回應 Origin：支援多來源時，必須根據請求動態回應 Vary Header 不可少：防止快取問題 善用 DevTools：Network 分頁是排查 CORS 的最佳工具 實戰建議：\n開發環境可以寬鬆設定，但生產環境務必嚴格限制 使用現成的 CORS 套件（如 cors）可以減少錯誤 在 Nginx 層級處理 CORS 可以統一管理 記得加入適當的錯誤日誌，方便追蹤問題 希望這篇文章能幫助你徹底理解 CORS，再也不用為那些紅色的錯誤訊息煩惱！\n參考資源 MDN - CORS W3C - CORS Specification Strapi Documentation - Middlewares Express CORS Middleware OWASP - CSRF Prevention Cheat Sheet ","permalink":"https://peter-notes.com/posts/frontend-login-cors-issues/","summary":"\u003ch2 id=\"前言那個令人抓狂的錯誤訊息\"\u003e前言：那個令人抓狂的錯誤訊息\u003c/h2\u003e\n\u003cp\u003e在開發前後端分離的 Web 應用時，幾乎每位工程師都曾遇過這個令人頭痛的錯誤：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eAccess to fetch at \u0026#39;http://localhost:1337/api/auth/local\u0026#39; from origin\n\u0026#39;http://localhost:5173\u0026#39; has been blocked by CORS policy: No\n\u0026#39;Access-Control-Allow-Origin\u0026#39; header is present on the requested resource.\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e這個錯誤通常發生在最關鍵的時刻：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e前端登入功能即將完成，卻無法呼叫後端 API\u003c/li\u003e\n\u003cli\u003e串接第三方服務時，資料無法正常取得\u003c/li\u003e\n\u003cli\u003e部署到測試環境後，原本運作正常的功能突然失效\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eCORS（Cross-Origin Resource Sharing，跨來源資源共用）是現代 Web 開發中的核心安全機制，但也是許多開發者的痛點。這篇文章將從基礎原理到實戰應用，帶你完整理解 CORS 的運作方式，並提供實際可用的解決方案。\u003c/p\u003e\n\u003ch2 id=\"為什麼需要-cors從同源政策說起\"\u003e為什麼需要 CORS？從同源政策說起\u003c/h2\u003e\n\u003ch3 id=\"同源政策的誕生\"\u003e同源政策的誕生\u003c/h3\u003e\n\u003cp\u003e在理解 CORS 之前，我們需要先認識「同源政策」（Same-Origin Policy, SOP）。這是瀏覽器最基本的安全機制，在 1995 年 Netscape Navigator 2.0 引入 JavaScript 時就已經存在。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e同源政策的目的\u003c/strong\u003e：防止惡意網站讀取另一個網站的敏感資料。\u003c/p\u003e\n\u003cp\u003e想像一個情境：你登入了網路銀行（\u003ccode\u003ehttps://bank.com\u003c/code\u003e），此時你的瀏覽器保存了銀行的登入 Cookie。如果沒有同源政策，當你不小心訪問了一個惡意網站（\u003ccode\u003ehttps://evil.com\u003c/code\u003e），該網站的 JavaScript 就能透過你的瀏覽器向 \u003ccode\u003ehttps://bank.com\u003c/code\u003e 發送請求，並讀取你的帳戶資料。\u003c/p\u003e\n\u003cp\u003e同源政策阻止了這種攻擊：\u003ccode\u003ehttps://evil.com\u003c/code\u003e 的 JavaScript 無法讀取 \u003ccode\u003ehttps://bank.com\u003c/code\u003e 的回應內容。\u003c/p\u003e\n\u003ch3 id=\"什麼是同源\"\u003e什麼是「同源」？\u003c/h3\u003e\n\u003cp\u003e兩個 URL 被視為同源，必須滿足以下三個條件：\u003c/p\u003e","title":"前端登入失敗的真兇：深入理解 CORS 問題與實戰解法"},{"content":"前言：為什麼需要 Pod？ 在 Kubernetes 的世界裡，Pod 是一切的基礎。如果把 Kubernetes 比喻成一座城市，那麼 Pod 就是城市中的「最小住宅單位」。\n但為什麼 Kubernetes 不直接管理容器（Container），而要多一層 Pod 的抽象？\n簡單回答：因為容器太小，Pod 剛剛好。\n想像你要管理一座城市的住宅：\n如果直接管理每個「房間」（容器）→ 太細碎，管理成本太高 如果直接管理整棟「大樓」（Node）→ 太粗糙，缺乏彈性 所以我們需要「住宅單位」（Pod）→ 大小適中，便於管理 本文將深入探討：\nPod 的核心概念與設計哲學 Pod 的內部架構與運作機制 Pod 網路模型與通訊方式 Pod 生命週期與狀態管理 Pod 設計模式與最佳實踐 實戰範例與 YAML 配置 Pod 核心概念：容器的邏輯主機 什麼是 Pod？ 官方定義：\nPod 是 Kubernetes 中最小的可部署計算單元，可以包含一個或多個容器，這些容器共享網路、儲存和其他資源。\n生活化比喻：\nPod 就像一個「邏輯主機」：\n在傳統架構中，多個應用程式運行在同一台虛擬機上 在 Kubernetes 中，多個容器運行在同一個 Pod 上 Pod 提供了容器之間的「緊密耦合」環境 Pod 的三大核心特性 1. 共享網路命名空間\n同一個 Pod 內的容器：\n✅ 共享同一個 IP 位址 ✅ 可以透過 localhost 互相通訊 ✅ 但 Port 不能衝突（每個容器用不同 Port） 2. 共享儲存卷（Volume）\n同一個 Pod 內的容器：\n✅ 可以掛載相同的 Volume ✅ 實現檔案共享（例如日誌、設定檔） ✅ 不需要透過網路傳輸 3. 原子化調度\nKubernetes 調度器（Scheduler）將 Pod 視為一個整體：\n整個 Pod 會被分配到同一個 Node 上 Pod 內的所有容器一起啟動、一起停止 資源限制（CPU、Memory）以 Pod 為單位計算 Pod 架構深度解析 Pod 的內部結構 容器類型說明：\n1. Pause 容器（基礎設施容器）\nKubernetes 自動創建的隱藏容器 作用：建立並持有網路命名空間 極其輕量（~700KB），幾乎不消耗資源 Pod 的生命週期由它決定 2. Init 容器（初始化容器）\n在應用容器啟動前執行 按順序執行（一個接一個） 用途：資料庫遷移、設定檔準備、等待依賴服務 失敗會導致 Pod 重啟 3. 應用容器（Main Containers）\n實際運行業務邏輯的容器 可以有一個或多個 同時啟動（並行） 4. Sidecar 容器（輔助容器）\n與主容器同時運行 用途：日誌收集、監控代理、服務網格（如 Envoy） 獨立的生命週期管理 Pod 網路模型 Kubernetes 網路三大原則：\n每個 Pod 有獨立的 IP Pod 之間可以直接通訊（不需要 NAT） Node 可以與所有 Pod 通訊 Pod 內部通訊範例：\n# Pod 定義 apiVersion: v1 kind: Pod metadata: name: multi-container-pod spec: containers: - name: nginx image: nginx:1.21 ports: - containerPort: 80 - name: log-agent image: fluent/fluentd:latest # 可以透過 localhost:80 存取 nginx 在 log-agent 容器內：\n# 直接用 localhost 訪問 nginx curl http://localhost:80 # 成功！因為共享網路命名空間 Pod 生命週期管理 Pod 的狀態機 Pod 生命週期階段詳解 1. Pending（等待中）\nPod 已被 Kubernetes 接受，但還沒開始運行：\n$ kubectl get pod my-pod NAME READY STATUS RESTARTS AGE my-pod 0/1 Pending 0 5s 可能原因：\n⏳ 等待調度器分配 Node ⏳ 正在拉取容器映像檔 ⏳ Init 容器正在執行 ❌ 資源不足（CPU、Memory） ❌ PersistentVolumeClaim 無法綁定 排查指令：\n# 查看詳細事件 kubectl describe pod my-pod # 常見錯誤訊息 Events: Type Reason Message ---- ------ ------- Warning FailedScheduling 0/3 nodes are available: insufficient memory 2. Running（運行中）\n至少一個容器正在運行：\n$ kubectl get pod my-pod NAME READY STATUS RESTARTS AGE my-pod 2/2 Running 0 1m READY 欄位解讀：\n2/2 → 2 個容器都就緒 1/2 → 只有 1 個容器就緒（另一個可能在啟動或崩潰） 0/2 → 沒有容器就緒（可能在重啟） 3. Succeeded（成功完成）\n所有容器都成功結束（退出碼 0）：\n$ kubectl get pod my-job-pod NAME READY STATUS RESTARTS AGE my-job-pod 0/1 Completed 0 2m 適用於：\nKubernetes Job CronJob 一次性任務（資料遷移、批次處理） 4. Failed（失敗）\n至少一個容器以非零退出碼結束：\n$ kubectl get pod my-pod NAME READY STATUS RESTARTS AGE my-pod 0/1 Error 0 30s 常見失敗狀態：\nError - 容器退出碼非零 CrashLoopBackOff - 容器反覆崩潰 ImagePullBackOff - 無法拉取映像檔 OOMKilled - 記憶體不足被殺掉 5. Unknown（未知）\n無法獲取 Pod 狀態（通常是 Node 失聯）：\n$ kubectl get pod my-pod NAME READY STATUS RESTARTS AGE my-pod 0/1 Unknown 0 5m Pod 重啟策略 apiVersion: v1 kind: Pod metadata: name: my-pod spec: restartPolicy: Always # Always | OnFailure | Never containers: - name: app image: myapp:v1 重啟策略對照表：\n策略 說明 適用場景 Always 容器退出就重啟（預設） 長期運行的服務（Web、API） OnFailure 只有失敗時才重啟 批次處理、資料處理 Never 從不重啟 一次性任務、偵錯 重啟行為：\nPod 設計模式 1. Sidecar 模式（最常見） **概念：**在主容器旁邊放一個輔助容器，提供額外功能。\n使用場景：\n日誌收集（Fluentd、Filebeat） 監控代理（Prometheus exporter） 服務網格（Istio Envoy） 配置熱更新 架構圖：\n實戰範例：Nginx + 日誌收集\napiVersion: v1 kind: Pod metadata: name: nginx-with-logging spec: containers: # 主容器：Nginx Web Server - name: nginx image: nginx:1.21 ports: - containerPort: 80 volumeMounts: - name: logs mountPath: /var/log/nginx # Sidecar 容器：日誌收集 - name: log-collector image: fluent/fluentd:latest volumeMounts: - name: logs mountPath: /var/log/nginx readOnly: true # 只讀，避免誤刪 volumes: - name: logs emptyDir: {} # 臨時儲存 2. Ambassador 模式（大使模式） **概念：**Sidecar 容器作為主容器與外部世界的代理。\n使用場景：\n資料庫連線池代理 Redis 集群代理 gRPC 負載平衡 架構圖：\n實戰範例：應用 + Redis Ambassador\napiVersion: v1 kind: Pod metadata: name: app-with-redis-proxy spec: containers: # 主容器：應用程式 - name: app image: myapp:v1 env: - name: REDIS_HOST value: \u0026#34;localhost\u0026#34; # 連到 Sidecar - name: REDIS_PORT value: \u0026#34;6379\u0026#34; # Ambassador 容器：Redis Proxy - name: redis-proxy image: haproxy:2.4 ports: - containerPort: 6379 volumeMounts: - name: config mountPath: /usr/local/etc/haproxy volumes: - name: config configMap: name: redis-proxy-config 3. Adapter 模式（適配器模式） **概念：**標準化主容器的輸出格式。\n使用場景：\n日誌格式轉換（JSON → Plain Text） 監控指標轉換（Prometheus 格式） 資料格式標準化 架構圖：\n實戰範例：日誌格式轉換\napiVersion: v1 kind: Pod metadata: name: app-with-adapter spec: containers: # 主容器：舊版應用（非標準日誌） - name: legacy-app image: old-app:1.0 volumeMounts: - name: logs mountPath: /var/log/app # Adapter 容器：日誌格式轉換 - name: log-adapter image: busybox command: - sh - -c - | tail -f /var/log/app/app.log | \\ awk \u0026#39;{print \u0026#34;{\\\u0026#34;timestamp\\\u0026#34;:\\\u0026#34;\u0026#34;strftime(\u0026#34;%Y-%m-%dT%H:%M:%S\u0026#34;)\u0026#34;\\\u0026#34; ,\\\u0026#34;message\\\u0026#34;:\\\u0026#34;\u0026#34;$0\u0026#34;\\\u0026#34;}\u0026#34;}\u0026#39; \\ \u0026gt; /var/log/standardized/app.json volumeMounts: - name: logs mountPath: /var/log/app readOnly: true - name: standardized-logs mountPath: /var/log/standardized volumes: - name: logs emptyDir: {} - name: standardized-logs emptyDir: {} 實戰配置範例 基本 Pod 配置 apiVersion: v1 kind: Pod metadata: name: simple-web-app labels: app: web tier: frontend spec: containers: - name: nginx image: nginx:1.21 ports: - containerPort: 80 protocol: TCP # 資源限制 resources: requests: memory: \u0026#34;64Mi\u0026#34; cpu: \u0026#34;250m\u0026#34; limits: memory: \u0026#34;128Mi\u0026#34; cpu: \u0026#34;500m\u0026#34; # 健康檢查 livenessProbe: httpGet: path: / port: 80 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: / port: 80 initialDelaySeconds: 5 periodSeconds: 5 使用 Init 容器 apiVersion: v1 kind: Pod metadata: name: app-with-init spec: # Init 容器（按順序執行） initContainers: - name: init-db-check image: busybox:1.35 command: - sh - -c - | until nc -z db-service 5432; do echo \u0026#34;Waiting for database...\u0026#34; sleep 2 done echo \u0026#34;Database is ready!\u0026#34; - name: init-migration image: myapp:v1 command: [\u0026#34;python\u0026#34;, \u0026#34;manage.py\u0026#34;, \u0026#34;migrate\u0026#34;] env: - name: DATABASE_URL valueFrom: secretKeyRef: name: db-secret key: url # 主容器 containers: - name: app image: myapp:v1 ports: - containerPort: 8000 多容器 Pod（完整範例） apiVersion: v1 kind: Pod metadata: name: full-stack-pod labels: app: my-app spec: # Init 容器：準備工作 initContainers: - name: setup-config image: busybox command: - sh - -c - | echo \u0026#34;Preparing configuration...\u0026#34; cp /config-template/* /app-config/ volumeMounts: - name: config-template mountPath: /config-template - name: app-config mountPath: /app-config # 主容器們 containers: # 1. 應用程式 - name: app image: myapp:v2.1 ports: - containerPort: 8080 env: - name: CONFIG_PATH value: /app-config volumeMounts: - name: app-config mountPath: /app-config - name: logs mountPath: /var/log/app resources: requests: memory: \u0026#34;256Mi\u0026#34; cpu: \u0026#34;500m\u0026#34; limits: memory: \u0026#34;512Mi\u0026#34; cpu: \u0026#34;1000m\u0026#34; livenessProbe: httpGet: path: /health port: 8080 initialDelaySeconds: 60 periodSeconds: 10 readinessProbe: httpGet: path: /ready port: 8080 initialDelaySeconds: 10 periodSeconds: 5 # 2. 日誌收集 Sidecar - name: log-collector image: fluent/fluentd:v1.14 volumeMounts: - name: logs mountPath: /var/log/app readOnly: true - name: fluentd-config mountPath: /fluentd/etc resources: requests: memory: \u0026#34;128Mi\u0026#34; cpu: \u0026#34;100m\u0026#34; limits: memory: \u0026#34;256Mi\u0026#34; cpu: \u0026#34;200m\u0026#34; # 3. 監控 Exporter Sidecar - name: metrics-exporter image: prom/node-exporter:latest ports: - containerPort: 9100 resources: requests: memory: \u0026#34;64Mi\u0026#34; cpu: \u0026#34;50m\u0026#34; limits: memory: \u0026#34;128Mi\u0026#34; cpu: \u0026#34;100m\u0026#34; # Volumes volumes: - name: config-template configMap: name: app-config-template - name: app-config emptyDir: {} - name: logs emptyDir: {} - name: fluentd-config configMap: name: fluentd-config # Pod 設定 restartPolicy: Always serviceAccountName: my-app-sa securityContext: runAsNonRoot: true runAsUser: 1000 fsGroup: 2000 Pod 管理與操作 常用 kubectl 指令 查看 Pod：\n# 列出所有 Pod kubectl get pods # 查看特定 namespace 的 Pod kubectl get pods -n production # 查看所有 namespace 的 Pod kubectl get pods --all-namespaces # 查看詳細資訊 kubectl get pods -o wide # 查看 YAML 定義 kubectl get pod my-pod -o yaml # 持續監控 Pod 狀態 kubectl get pods -w 查看 Pod 詳情：\n# 查看 Pod 詳細資訊（包含事件） kubectl describe pod my-pod # 查看特定容器的資訊 kubectl describe pod my-pod | grep -A 10 \u0026#34;Container ID\u0026#34; 查看 Pod 日誌：\n# 查看 Pod 日誌 kubectl logs my-pod # 查看特定容器的日誌（多容器 Pod） kubectl logs my-pod -c nginx # 即時查看日誌（類似 tail -f） kubectl logs -f my-pod # 查看之前崩潰的容器日誌 kubectl logs my-pod --previous # 查看最近 100 行日誌 kubectl logs my-pod --tail=100 # 加上時間戳記 kubectl logs my-pod --timestamps 進入 Pod 容器：\n# 進入 Pod 的第一個容器 kubectl exec -it my-pod -- /bin/bash # 進入特定容器 kubectl exec -it my-pod -c nginx -- /bin/sh # 執行單一指令 kubectl exec my-pod -- ls /app # 在特定容器執行指令 kubectl exec my-pod -c nginx -- nginx -t 複製檔案：\n# 從 Pod 複製到本地 kubectl cp my-pod:/var/log/app.log ./app.log # 從本地複製到 Pod kubectl cp ./config.yaml my-pod:/etc/config/ # 複製到特定容器 kubectl cp my-pod:/data/export.csv ./export.csv -c app 刪除 Pod：\n# 刪除單個 Pod kubectl delete pod my-pod # 刪除多個 Pod kubectl delete pod pod1 pod2 pod3 # 使用標籤選擇器刪除 kubectl delete pods -l app=nginx # 強制刪除（不等待 Grace Period） kubectl delete pod my-pod --grace-period=0 --force # 刪除所有 Pod kubectl delete pods --all Pod 最佳實踐 1. 資源管理 總是設定資源請求和限制：\nspec: containers: - name: app resources: requests: # 最小保證 memory: \u0026#34;256Mi\u0026#34; cpu: \u0026#34;500m\u0026#34; limits: # 最大限制 memory: \u0026#34;512Mi\u0026#34; cpu: \u0026#34;1000m\u0026#34; 原則：\n✅ requests 用於調度決策 ✅ limits 用於資源限制 ✅ 避免設定過大的 limits（容易 OOMKilled） ✅ CPU 可以超賣，Memory 不可以 2. 健康檢查 同時設定 Liveness 和 Readiness Probe：\nspec: containers: - name: app livenessProbe: # 檢查是否存活 httpGet: path: /health port: 8080 initialDelaySeconds: 60 periodSeconds: 10 failureThreshold: 3 readinessProbe: # 檢查是否就緒 httpGet: path: /ready port: 8080 initialDelaySeconds: 10 periodSeconds: 5 failureThreshold: 3 差異：\nlivenessProbe 失敗 → 重啟容器 readinessProbe 失敗 → 從 Service 移除，但不重啟 3. 優雅關閉 spec: containers: - name: app lifecycle: preStop: exec: command: - sh - -c - | # 停止接受新請求 echo \u0026#34;Stopping accepting new requests...\u0026#34; # 等待現有請求完成 sleep 15 # 清理資源 echo \u0026#34;Cleaning up...\u0026#34; terminationGracePeriodSeconds: 30 # 給予 30 秒關閉時間 4. 避免單一容器做太多事 ❌ 不好的做法：\n# 單一容器包含 Web Server + Database + Cache containers: - name: all-in-one image: my-monolith:v1 ✅ 好的做法：\n# 分離關注點 containers: - name: web image: nginx:1.21 - name: app image: myapp:v2 - name: cache image: redis:6.2 5. 使用標籤和註解 metadata: name: my-pod labels: app: web tier: frontend version: v2.1 environment: production annotations: prometheus.io/scrape: \u0026#34;true\u0026#34; prometheus.io/port: \u0026#34;9090\u0026#34; description: \u0026#34;Main web application pod\u0026#34; 常見問題與解決方案 Q1: 什麼時候該用多容器 Pod？ 應該用多容器：\n✅ 容器之間需要緊密耦合（Sidecar 模式） ✅ 容器需要共享檔案系統 ✅ 容器需要在同一台機器上（例如 localhost 通訊） 不應該用多容器：\n❌ 獨立的微服務（應該用多個 Pod） ❌ 可以透過網路通訊的服務 ❌ 需要獨立擴展的應用 Q2: Pod 和 Deployment 有什麼區別？ 特性 Pod Deployment 層級 最小單元 控制器 副本 單一實例 管理多個副本 自癒 不會自動重建 自動重建 更新 不支援滾動更新 支援滾動更新 使用場景 偵錯、測試 生產環境 **結論：**生產環境應該使用 Deployment，而不是直接創建 Pod。\nQ3: 為什麼我的 Pod 一直 Pending？ 排查步驟：\n# 1. 查看 Pod 事件 kubectl describe pod my-pod # 常見原因： # - Insufficient CPU/Memory（資源不足） # - No nodes available（沒有可用節點） # - PVC not bound（儲存卷未綁定） # - Image pull error（映像檔拉取失敗） # 2. 查看節點資源 kubectl top nodes # 3. 查看 PVC 狀態 kubectl get pvc Q4: 如何在 Pod 之間共享資料？ 不推薦在 Pod 之間直接共享 Volume（不同 Pod 可能在不同 Node）\n推薦方案：\n使用網路儲存（NFS、S3） 使用資料庫或快取（Redis、PostgreSQL） 使用訊息佇列（RabbitMQ、Kafka） Q5: Init 容器失敗了怎麼辦？ Init 容器失敗會導致 Pod 無法啟動：\n# 查看 Init 容器日誌 kubectl logs my-pod -c init-db-check # Pod 會不斷重試 Init 容器 # 直到成功或達到 BackoffLimit 解決方案：\n修正 Init 容器的錯誤 檢查依賴服務是否就緒 調整超時設定 總結與收穫 關鍵要點 Pod 核心概念：\n✅ Pod 是 K8s 最小部署單元：容器的邏輯主機 ✅ 共享網路和儲存：Pod 內容器可以用 localhost 通訊 ✅ 原子化調度：整個 Pod 被視為一個整體 Pod 設計模式：\n✅ Sidecar：輔助容器提供額外功能（日誌、監控） ✅ Ambassador：代理容器簡化外部通訊 ✅ Adapter：轉換容器標準化輸出 生產環境建議：\n✅ 使用 Deployment 而非直接創建 Pod ✅ 設定資源 requests 和 limits ✅ 配置健康檢查（Liveness \u0026amp; Readiness） ✅ 實現優雅關閉 ✅ 使用標籤和註解管理 從 Pod 到 Kubernetes 生態 理解 Pod 只是第一步，接下來你可以探索：\nDeployment - 管理 Pod 副本和滾動更新 Service - 為 Pod 提供穩定的網路端點 ConfigMap / Secret - 管理設定和敏感資料 PersistentVolume - 持久化儲存 StatefulSet - 有狀態應用 DaemonSet - 每個節點運行一個 Pod Job / CronJob - 批次任務 Pod 是 Kubernetes 的基石，掌握 Pod 的概念和使用方式，你就能理解整個 Kubernetes 生態系統的運作原理。\n參考資源 官方文件：\nKubernetes Pods Pod Lifecycle Init Containers Multi-Container Pod Patterns 實用工具：\nkubectl Cheat Sheet Kube-ps1 - Shell 提示符顯示當前 context k9s - 強大的 K8s CLI UI 延伸閱讀：\nThe Twelve-Factor App - 雲端原生應用設計原則 Kubernetes Patterns - K8s 設計模式 Production Best Practices ","permalink":"https://peter-notes.com/posts/what-is-k8s-pod/","summary":"\u003ch2 id=\"前言為什麼需要-pod\"\u003e前言：為什麼需要 Pod？\u003c/h2\u003e\n\u003cp\u003e在 Kubernetes 的世界裡，\u003cstrong\u003ePod\u003c/strong\u003e 是一切的基礎。如果把 Kubernetes 比喻成一座城市，那麼 Pod 就是城市中的「最小住宅單位」。\u003c/p\u003e\n\u003cp\u003e但為什麼 Kubernetes 不直接管理容器（Container），而要多一層 Pod 的抽象？\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e簡單回答：因為容器太小，Pod 剛剛好。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e想像你要管理一座城市的住宅：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e如果直接管理每個「房間」（容器）→ 太細碎，管理成本太高\u003c/li\u003e\n\u003cli\u003e如果直接管理整棟「大樓」（Node）→ 太粗糙，缺乏彈性\u003c/li\u003e\n\u003cli\u003e所以我們需要「住宅單位」（Pod）→ 大小適中，便於管理\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e本文將深入探討：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003ePod 的核心概念與設計哲學\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePod 的內部架構與運作機制\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePod 網路模型與通訊方式\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePod 生命週期與狀態管理\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePod 設計模式與最佳實踐\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e實戰範例與 YAML 配置\u003c/strong\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"pod-核心概念容器的邏輯主機\"\u003ePod 核心概念：容器的邏輯主機\u003c/h2\u003e\n\u003ch3 id=\"什麼是-pod\"\u003e什麼是 Pod？\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e官方定義：\u003c/strong\u003e\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003ePod 是 Kubernetes 中最小的可部署計算單元，可以包含一個或多個容器，這些容器共享網路、儲存和其他資源。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003e生活化比喻：\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/2025-06-12-在k8s的pod是什麼-diagram-5da01d73.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e\u003cstrong\u003ePod 就像一個「邏輯主機」\u003c/strong\u003e：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e在傳統架構中，多個應用程式運行在同一台虛擬機上\u003c/li\u003e\n\u003cli\u003e在 Kubernetes 中，多個容器運行在同一個 Pod 上\u003c/li\u003e\n\u003cli\u003ePod 提供了容器之間的「緊密耦合」環境\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"pod-的三大核心特性\"\u003ePod 的三大核心特性\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e1. 共享網路命名空間\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/2025-06-12-在k8s的pod是什麼-diagram-44b4234f.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e同一個 Pod 內的容器：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e✅ 共享同一個 IP 位址\u003c/li\u003e\n\u003cli\u003e✅ 可以透過 \u003ccode\u003elocalhost\u003c/code\u003e 互相通訊\u003c/li\u003e\n\u003cli\u003e✅ 但 Port 不能衝突（每個容器用不同 Port）\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e2. 共享儲存卷（Volume）\u003c/strong\u003e\u003c/p\u003e","title":"深入理解 Kubernetes Pod：從基礎概念到實戰應用"},{"content":"引言：打破手動部署的迷思 「為什麼我的 CI 已經產出 prod-0.54，卻還得手動去跑 kubectl apply -f deployment.yaml？那不是多此一舉嗎？」\n如果你也曾陷入這樣的疑問，本文將從根本理清 CI/CD 與 Kubernetes 之間的分工，並學會如何「一鍵從程式碼到雲端服務」完全自動化。\nCI/CD vs. Kubernetes：各司其職的完美搭檔 在軟體開發的世界裡，GitLab CI/CD 和 Kubernetes 常常被搭在一起討論，卻扮演著截然不同的角色。\nCI/CD 的職責：生產線 GitLab CI/CD 的工作內容：\n建置（Build）：將程式碼打包成 Docker 映像 標記（Tag）：為映像貼上版本號標籤（例如 0.54、v1.0.0） 推送（Push）：把 Docker 映像推到映像庫（AWS ECR、Docker Hub） Kubernetes 的職責：配送中心 Kubernetes 的工作內容：\n部署（Deploy）：在叢集裡建立 Pod 並執行容器 監控（Monitor）：監控運行狀況，Pod 死掉自動重啟 更新（Update）：滾動更新（Rolling Update）時保證服務不中斷 維運（Operate）：調整副本數量、健康檢查、網路規則 分工比喻 如果把軟體交付比喻成流水線：\n角色 比喻 職責 CI/CD 工廠組裝工人 把原料（程式碼）生產成成品（Docker 映像），打上編號（Tag） Kubernetes 物流配送中心 拿到成品後送到倉庫（叢集），確保正確分配、穩定運行 ⚠️ 關鍵問題： 若只把「生產出映像」交給 CI/CD，卻沒有「派送到叢集裡面運行」的步驟，流程就會中斷——就好比你生產一箱箱可口可樂，卻一直放在廠區裡沒人去配送到超商。\n完整自動化流程架構 讓我們先看看完整的自動化部署流程：\n流程說明：\n開發者 Push 程式碼到 GitLab CI Pipeline 自動觸發，執行 Build → Push → Deploy Kubernetes 從 ECR 拉取新映像，執行滾動更新 完成部署，服務零中斷升級 第一部分：GitLab CI Pipeline 設定 完整 .gitlab-ci.yml 範例 # .gitlab-ci.yml stages: - build - push - deploy variables: AWS_ACCOUNT_ID: 781267011388 AWS_REGION: ap-east-1 IMAGE_NAME: company-web # ============================================ # Stage 1: Build Docker Image # ============================================ build: stage: build image: docker:latest services: - docker:dind script: # 建置 Docker 映像，使用 commit SHA 作為 tag - docker build -t $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA . # 將 commit SHA 寫入檔案，供後續 stage 使用 - echo $CI_COMMIT_SHORT_SHA \u0026gt; image_tag.txt artifacts: paths: - image_tag.txt expire_in: 1 hour # ============================================ # Stage 2: Push to AWS ECR # ============================================ push: stage: push image: docker:latest services: - docker:dind before_script: # 安裝 AWS CLI（若映像中沒有） - apk add --no-cache aws-cli script: # 1. 登入 AWS ECR - aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com # 2. 讀取 build stage 產生的 tag - IMAGE_TAG=$(cat image_tag.txt) # 3. 推送帶有 commit SHA 的映像 - docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_NAME:$IMAGE_TAG # 4. 同時標記為 latest（可選） - docker tag $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_NAME:$IMAGE_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_NAME:latest - docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_NAME:latest only: - main - master # ============================================ # Stage 3: Deploy to Kubernetes # ============================================ deploy: stage: deploy image: bitnami/kubectl:latest before_script: # 設定 kubeconfig（使用 GitLab CI/CD Variables） - mkdir -p ~/.kube - echo \u0026#34;$KUBE_CONFIG\u0026#34; \u0026gt; ~/.kube/config script: # 1. 讀取映像 tag - IMAGE_TAG=$(cat image_tag.txt) # 2. 更新 Deployment 的映像版本 - kubectl set image deployment/company-web company-web-prod=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_NAME:$IMAGE_TAG -n prod # 3. 等待 Rollout 完成 - kubectl rollout status deployment/company-web -n prod --timeout=5m # 4. 驗證部署結果 - kubectl get pods -n prod -l app=company-web only: - main - master Pipeline 各階段詳解 階段流程圖：\n1. Build 階段\n使用 docker:dind（Docker in Docker）服務 以 commit SHA（例如 6651c41e）作為映像標籤 將 tag 寫入 image_tag.txt 作為 artifact，供後續階段使用 2. Push 階段\n登入 AWS ECR（使用 IAM credentials） 推送兩個版本： company-web:6651c41e（精確版本） company-web:latest（測試環境使用） 3. Deploy 階段\n使用 kubectl set image 更新 Deployment 等待 Rolling Update 完成（最多 5 分鐘） 驗證新 Pod 是否正常運行 第二部分：Kubernetes Deployment 設定 完整 Deployment YAML # company_web_prod_deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: company-web namespace: prod labels: app: company-web env: prod spec: # 副本數量 replicas: 3 # 滾動更新策略 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 # 最多可以多出 1 個 Pod maxUnavailable: 0 # 更新時至少保持所有 Pod 可用 # Pod 選擇器 selector: matchLabels: app: company-web env: prod # Pod 模板 template: metadata: labels: app: company-web env: prod spec: containers: - name: company-web-prod image: 781267011388.dkr.ecr.ap-east-1.amazonaws.com/company-web:0.54 # 容器端口 ports: - containerPort: 80 protocol: TCP # 環境變數 env: - name: NODE_ENV value: \u0026#34;production\u0026#34; - name: PORT value: \u0026#34;80\u0026#34; # 資源限制 resources: requests: memory: \u0026#34;256Mi\u0026#34; cpu: \u0026#34;250m\u0026#34; limits: memory: \u0026#34;512Mi\u0026#34; cpu: \u0026#34;500m\u0026#34; # 存活探針（Liveness Probe） livenessProbe: httpGet: path: /_health port: 80 scheme: HTTP initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 # 就緒探針（Readiness Probe） readinessProbe: httpGet: path: /_health port: 80 scheme: HTTP initialDelaySeconds: 10 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 # ImagePullPolicy imagePullPolicy: IfNotPresent Deployment 關鍵設定說明 1. 副本數與高可用性 replicas: 3 確保至少有 3 個 Pod 同時運行 若某個 Pod 失敗，K8s 自動重建 擴展到 10 個？只需改成 replicas: 10 並 apply 2. 滾動更新策略 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 # 最多可以多出 1 個 Pod maxUnavailable: 0 # 更新時至少保持所有 Pod 可用 滾動更新流程：\n關鍵優勢：\n✅ 零停機時間：至少 3 個 Pod 隨時可用 ✅ 逐步驗證：每個新 Pod 通過 Health Check 才繼續 ✅ 快速回滾：若新版本失敗，可立即 rollback 3. Label 與 Selector selector: matchLabels: app: company-web env: prod template: metadata: labels: app: company-web env: prod Label 的用途：\n識別管理：Deployment 透過 label 識別哪些 Pod 屬於它 Service 路由：Service 透過 label selector 決定流量要送到哪些 Pod 查詢過濾：kubectl get pods -l app=company-web 4. Health Check 機制 Liveness Probe（存活探針）\n目的：檢查容器是否「活著」 失敗後果：連續 3 次失敗 → K8s 自動 kill 並重建 Pod 使用場景：防止應用程式死鎖或進入不可恢復狀態 Readiness Probe（就緒探針）\n目的：檢查容器是否「準備好接流量」 失敗後果：從 Service Endpoints 移除，不再接收流量 使用場景：應用啟動慢，需要初始化資料庫連線等 實際範例：\n假設你的應用需要 20 秒初始化：\nreadinessProbe: httpGet: path: /_health port: 80 initialDelaySeconds: 10 # 啟動後等 10 秒再檢查 periodSeconds: 5 # 每 5 秒檢查一次 failureThreshold: 3 # 連續失敗 3 次才算失敗 5. 資源管理 resources: requests: # 最低保證資源 memory: \u0026#34;256Mi\u0026#34; cpu: \u0026#34;250m\u0026#34; limits: # 最大可用資源 memory: \u0026#34;512Mi\u0026#34; cpu: \u0026#34;500m\u0026#34; 資源設定最佳實踐：\n場景 CPU Request CPU Limit Memory Request Memory Limit 小型服務 100m 200m 128Mi 256Mi 中型服務 250m 500m 256Mi 512Mi 大型服務 500m 1000m 512Mi 1Gi ⚠️ 注意： 若 Pod 超過 Memory Limit，會被 OOMKilled（Out of Memory Killed）\n第三部分：完整自動化部署流程 端到端流程 實際操作步驟 步驟 1：Commit \u0026amp; Merge # 開發者本地修改程式碼 git add . git commit -m \u0026#34;feat: 新增使用者驗證功能\u0026#34; git push origin main GitLab CI 自動觸發，開始 Build 階段。\n步驟 2：CI Build 階段 # GitLab Runner 執行 docker build -t 781267011388.dkr.ecr.ap-east-1.amazonaws.com/company-web:6651c41e . # 產生 artifact echo 6651c41e \u0026gt; image_tag.txt 輸出結果：\nStep 1/8 : FROM node:18-alpine Step 2/8 : WORKDIR /app Step 3/8 : COPY package*.json ./ Step 4/8 : RUN npm ci --only=production Step 5/8 : COPY . . Step 6/8 : EXPOSE 80 Step 7/8 : CMD [\u0026#34;node\u0026#34;, \u0026#34;server.js\u0026#34;] Step 8/8 : Successfully built abc123def456 Successfully tagged 781267011388.dkr.ecr.ap-east-1.amazonaws.com/company-web:6651c41e 步驟 3：CI Push 階段 # 登入 ECR aws ecr get-login-password --region ap-east-1 | \\ docker login --username AWS --password-stdin \\ 781267011388.dkr.ecr.ap-east-1.amazonaws.com # 推送映像 docker push 781267011388.dkr.ecr.ap-east-1.amazonaws.com/company-web:6651c41e docker push 781267011388.dkr.ecr.ap-east-1.amazonaws.com/company-web:latest 輸出結果：\nThe push refers to repository [781267011388.dkr.ecr.ap-east-1.amazonaws.com/company-web] 6651c41e: Pushed latest: Pushed 步驟 4：CI Deploy 階段 # 更新 Deployment 映像 kubectl set image deployment/company-web \\ company-web-prod=781267011388.dkr.ecr.ap-east-1.amazonaws.com/company-web:6651c41e \\ -n prod # 等待 Rollout 完成 kubectl rollout status deployment/company-web -n prod 輸出結果：\ndeployment.apps/company-web image updated Waiting for deployment \u0026#34;company-web\u0026#34; rollout to finish: 1 out of 3 new replicas have been updated... Waiting for deployment \u0026#34;company-web\u0026#34; rollout to finish: 2 out of 3 new replicas have been updated... Waiting for deployment \u0026#34;company-web\u0026#34; rollout to finish: 1 old replicas are pending termination... deployment \u0026#34;company-web\u0026#34; successfully rolled out 步驟 5：驗證部署 # 查看 Pod 狀態 kubectl get pods -n prod -l app=company-web 輸出結果：\nNAME READY STATUS RESTARTS AGE company-web-7d4b8f9c5d-abc12 1/1 Running 0 2m company-web-7d4b8f9c5d-def34 1/1 Running 0 2m company-web-7d4b8f9c5d-ghi56 1/1 Running 0 1m 第四部分：常見問題診斷與解決 問題診斷流程圖 問題 1：ImagePullBackOff 症狀：\n$ kubectl get pods -n prod NAME READY STATUS RESTARTS AGE company-web-7d4b8f9c5d-abc12 0/1 ImagePullBackOff 0 5m 可能原因：\nECR 裡沒有該 tag 的映像 Kubernetes Node 沒有 ECR 拉取權限 imagePullPolicy 設定問題 診斷步驟：\n# 1. 查看 Pod 詳細資訊 kubectl describe pod company-web-7d4b8f9c5d-abc12 -n prod 輸出範例：\nEvents: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Pulling 3m (x4 over 5m) kubelet Pulling image \u0026#34;781267011388.dkr.ecr.ap-east-1.amazonaws.com/company-web:0.54\u0026#34; Warning Failed 3m (x4 over 5m) kubelet Failed to pull image: rpc error: code = Unknown desc = Error response from daemon: manifest for 781267011388.dkr.ecr.ap-east-1.amazonaws.com/company-web:0.54 not found: manifest unknown 解決方案：\n# 檢查 ECR 是否有該映像 aws ecr describe-images \\ --repository-name company-web \\ --image-ids imageTag=0.54 \\ --region ap-east-1 # 若沒有，檢查 CI Pipeline 是否成功 # 若有，檢查 Node IAM Role 是否有 ECR 拉取權限 問題 2：CrashLoopBackOff 症狀：\n$ kubectl get pods -n prod NAME READY STATUS RESTARTS AGE company-web-7d4b8f9c5d-abc12 0/1 CrashLoopBackOff 5 3m 可能原因：\n應用程式啟動失敗（例如環境變數錯誤） Health Check 路徑錯誤 端口綁定失敗 診斷步驟：\n# 查看容器日誌 kubectl logs company-web-7d4b8f9c5d-abc12 -n prod # 查看前一次崩潰的日誌 kubectl logs company-web-7d4b8f9c5d-abc12 -n prod --previous 輸出範例：\nError: Cannot find module \u0026#39;/app/server.js\u0026#39; at Function.Module._resolveFilename (internal/modules/cjs/loader.js:636:15) at Function.Module._load (internal/modules/cjs/loader.js:562:25) 解決方案：\n# 檢查 Dockerfile 是否正確複製檔案 # 檢查 CMD 指令是否正確 # 檢查環境變數是否完整 問題 3：ProgressDeadlineExceeded 症狀：\n$ kubectl rollout status deployment/company-web -n prod error: deployment \u0026#34;company-web\u0026#34; exceeded its progress deadline 可能原因：\n新 Pod 一直無法通過 Readiness Probe 映像拉取時間過長 資源不足導致 Pod 無法調度 診斷步驟：\n# 查看 Deployment 事件 kubectl describe deployment company-web -n prod # 查看 ReplicaSet 狀態 kubectl get rs -n prod 解決方案：\n# 回滾到前一個版本 kubectl rollout undo deployment/company-web -n prod # 檢查並修復問題後，重新部署 kubectl rollout restart deployment/company-web -n prod 問題診斷 Checklist 檢查項目 指令 預期結果 Pod 狀態 kubectl get pods -n prod Running 且 READY 1/1 Deployment 狀態 kubectl get deployment -n prod READY 3/3 ReplicaSet 狀態 kubectl get rs -n prod 新的 RS 有 3 個 Pod Events kubectl get events -n prod --sort-by='.lastTimestamp' 無錯誤事件 容器日誌 kubectl logs \u0026lt;pod-name\u0026gt; -n prod 無錯誤訊息 ECR 映像 aws ecr describe-images --repository-name company-web Tag 存在 第五部分：進階優化與最佳實踐 1. 自動回滾機制 在 .gitlab-ci.yml 中加入回滾邏輯：\ndeploy: stage: deploy image: bitnami/kubectl:latest script: - IMAGE_TAG=$(cat image_tag.txt) - kubectl set image deployment/company-web company-web-prod=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_NAME:$IMAGE_TAG -n prod # 等待 Rollout，若失敗則自動回滾 - | if ! kubectl rollout status deployment/company-web -n prod --timeout=5m; then echo \u0026#34;❌ Rollout 失敗，開始自動回滾\u0026#34; kubectl rollout undo deployment/company-web -n prod kubectl rollout status deployment/company-web -n prod --timeout=3m exit 1 fi - echo \u0026#34;✅ 部署成功\u0026#34; 2. Smoke Test（冒煙測試） 部署完成後自動驗證服務可用性：\nstages: - build - push - deploy - verify verify: stage: verify image: curlimages/curl:latest script: # 等待服務完全啟動 - sleep 10 # 檢查健康檢查端點 - | for i in {1..5}; do if curl -f http://company-web.prod.svc.cluster.local/_health; then echo \u0026#34;✅ 健康檢查通過\u0026#34; exit 0 fi echo \u0026#34;⏳ 等待服務啟動... ($i/5)\u0026#34; sleep 5 done - echo \u0026#34;❌ 健康檢查失敗\u0026#34; - exit 1 3. 多環境部署策略 # 使用 GitLab Environment 管理不同環境 deploy_staging: stage: deploy script: - kubectl set image deployment/company-web company-web=$IMAGE:$TAG -n staging environment: name: staging url: https://staging.company.com only: - develop deploy_production: stage: deploy script: - kubectl set image deployment/company-web company-web=$IMAGE:$TAG -n prod environment: name: production url: https://company.com only: - main when: manual # 需要手動觸發 4. 監控與通知 整合 Slack 通知：\nnotify_success: stage: .post image: curlimages/curl:latest script: - | curl -X POST https://hooks.slack.com/services/YOUR/WEBHOOK/URL \\ -H \u0026#39;Content-Type: application/json\u0026#39; \\ -d \u0026#39;{ \u0026#34;text\u0026#34;: \u0026#34;✅ 部署成功\u0026#34;, \u0026#34;attachments\u0026#34;: [{ \u0026#34;color\u0026#34;: \u0026#34;good\u0026#34;, \u0026#34;fields\u0026#34;: [ {\u0026#34;title\u0026#34;: \u0026#34;環境\u0026#34;, \u0026#34;value\u0026#34;: \u0026#34;Production\u0026#34;, \u0026#34;short\u0026#34;: true}, {\u0026#34;title\u0026#34;: \u0026#34;版本\u0026#34;, \u0026#34;value\u0026#34;: \u0026#34;\u0026#39;$CI_COMMIT_SHORT_SHA\u0026#39;\u0026#34;, \u0026#34;short\u0026#34;: true}, {\u0026#34;title\u0026#34;: \u0026#34;提交者\u0026#34;, \u0026#34;value\u0026#34;: \u0026#34;\u0026#39;$GITLAB_USER_NAME\u0026#39;\u0026#34;, \u0026#34;short\u0026#34;: true} ] }] }\u0026#39; when: on_success notify_failure: stage: .post image: curlimages/curl:latest script: - | curl -X POST https://hooks.slack.com/services/YOUR/WEBHOOK/URL \\ -H \u0026#39;Content-Type: application/json\u0026#39; \\ -d \u0026#39;{ \u0026#34;text\u0026#34;: \u0026#34;❌ 部署失敗\u0026#34;, \u0026#34;attachments\u0026#34;: [{ \u0026#34;color\u0026#34;: \u0026#34;danger\u0026#34;, \u0026#34;fields\u0026#34;: [ {\u0026#34;title\u0026#34;: \u0026#34;環境\u0026#34;, \u0026#34;value\u0026#34;: \u0026#34;Production\u0026#34;, \u0026#34;short\u0026#34;: true}, {\u0026#34;title\u0026#34;: \u0026#34;Pipeline\u0026#34;, \u0026#34;value\u0026#34;: \u0026#34;\u0026#39;$CI_PIPELINE_URL\u0026#39;\u0026#34;, \u0026#34;short\u0026#34;: true} ] }] }\u0026#39; when: on_failure 結論：完整自動化的最後一哩路 關鍵要點回顧 ✅ CI/CD + Kubernetes 分工明確\nCI 負責「建置與上傳映像」 Kubernetes 負責「拉映像並執行容器」 ✅ 完整 Pipeline 四階段\nBuild：docker build + 標記 commit SHA Push：推送到 ECR（精確版本 + latest） Deploy：kubectl set image 更新 Deployment Verify：健康檢查 + Smoke Test ✅ 零停機部署\nRolling Update 策略 Health Check 機制 自動回滾保護 ✅ 問題診斷能力\nImagePullBackOff → 檢查 ECR + IAM CrashLoopBackOff → 查看日誌 + Health Check ProgressDeadlineExceeded → 回滾並修復 完整流程檢查清單 部署成功的 Pipeline 應該看到：\n✓ lint/test # 程式碼檢查與測試 ✓ build # Docker 映像建置 ✓ push # 推送到 ECR ✓ deploy # Kubernetes 更新 ✓ verify # 服務驗證 ✅ Deployment \u0026#34;company-web\u0026#34; successfully rolled out 下一步建議 實作 GitOps：使用 ArgoCD 或 Flux 管理 Kubernetes 設定 加強監控：整合 Prometheus + Grafana 優化建置：使用 Docker Layer Caching 加速 安全掃描：整合 Trivy 或 Snyk 掃描映像漏洞 參考資源 GitLab CI/CD 官方文件 Kubernetes Deployments 官方文件 AWS ECR 使用指南 Docker 最佳實踐 Rolling Update 策略詳解 Health Check 配置指南 ","permalink":"https://peter-notes.com/posts/gitlab-ci-to-k8s-deployment/","summary":"\u003ch2 id=\"引言打破手動部署的迷思\"\u003e引言：打破手動部署的迷思\u003c/h2\u003e\n\u003cblockquote\u003e\n\u003cp\u003e「為什麼我的 CI 已經產出 \u003ccode\u003eprod-0.54\u003c/code\u003e，卻還得手動去跑 \u003ccode\u003ekubectl apply -f deployment.yaml\u003c/code\u003e？那不是多此一舉嗎？」\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e如果你也曾陷入這樣的疑問，本文將從根本理清 \u003cstrong\u003eCI/CD 與 Kubernetes 之間的分工\u003c/strong\u003e，並學會如何「一鍵從程式碼到雲端服務」完全自動化。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"cicd-vs-kubernetes各司其職的完美搭檔\"\u003eCI/CD vs. Kubernetes：各司其職的完美搭檔\u003c/h2\u003e\n\u003cp\u003e在軟體開發的世界裡，\u003cstrong\u003eGitLab CI/CD\u003c/strong\u003e 和 \u003cstrong\u003eKubernetes\u003c/strong\u003e 常常被搭在一起討論，卻扮演著截然不同的角色。\u003c/p\u003e\n\u003ch3 id=\"cicd-的職責生產線\"\u003eCI/CD 的職責：生產線\u003c/h3\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/2025-06-05-如何從-gitlab-ci-一路自動化到-kubernetes-部署一次搞懂-diagram-96674379.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e\u003cstrong\u003eGitLab CI/CD 的工作內容：\u003c/strong\u003e\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e建置（Build）\u003c/strong\u003e：將程式碼打包成 Docker 映像\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e標記（Tag）\u003c/strong\u003e：為映像貼上版本號標籤（例如 \u003ccode\u003e0.54\u003c/code\u003e、\u003ccode\u003ev1.0.0\u003c/code\u003e）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e推送（Push）\u003c/strong\u003e：把 Docker 映像推到映像庫（AWS ECR、Docker Hub）\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch3 id=\"kubernetes-的職責配送中心\"\u003eKubernetes 的職責：配送中心\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eKubernetes 的工作內容：\u003c/strong\u003e\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e部署（Deploy）\u003c/strong\u003e：在叢集裡建立 Pod 並執行容器\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e監控（Monitor）\u003c/strong\u003e：監控運行狀況，Pod 死掉自動重啟\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e更新（Update）\u003c/strong\u003e：滾動更新（Rolling Update）時保證服務不中斷\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e維運（Operate）\u003c/strong\u003e：調整副本數量、健康檢查、網路規則\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch3 id=\"分工比喻\"\u003e分工比喻\u003c/h3\u003e\n\u003cp\u003e如果把軟體交付比喻成流水線：\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e角色\u003c/th\u003e\n          \u003cth\u003e比喻\u003c/th\u003e\n          \u003cth\u003e職責\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eCI/CD\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e工廠組裝工人\u003c/td\u003e\n          \u003ctd\u003e把原料（程式碼）生產成成品（Docker 映像），打上編號（Tag）\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003eKubernetes\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e物流配送中心\u003c/td\u003e\n          \u003ctd\u003e拿到成品後送到倉庫（叢集），確保正確分配、穩定運行\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cblockquote\u003e\n\u003cp\u003e⚠️ \u003cstrong\u003e關鍵問題：\u003c/strong\u003e 若只把「生產出映像」交給 CI/CD，卻沒有「派送到叢集裡面運行」的步驟，流程就會中斷——就好比你生產一箱箱可口可樂，卻一直放在廠區裡沒人去配送到超商。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"完整自動化流程架構\"\u003e完整自動化流程架構\u003c/h2\u003e\n\u003cp\u003e讓我們先看看完整的自動化部署流程：\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/2025-06-05-如何從-gitlab-ci-一路自動化到-kubernetes-部署一次搞懂-diagram-7539204e.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e\u003cstrong\u003e流程說明：\u003c/strong\u003e\u003c/p\u003e","title":"從 GitLab CI 到 Kubernetes 自動化部署：完整 CI/CD Pipeline 實戰指南"},{"content":"前言 在現代雲端架構中，資料庫通常部署在受保護的私有網路環境（Private Subnet）中，以提升安全性。AWS RDS（Relational Database Service）作為主流的托管資料庫服務，提供了多種連線方式，但對於初學者來說，如何在不同環境（本機、Docker、Kubernetes）中正確連線到 RDS 往往充滿挑戰。\n這篇文章將深入探討：\nAWS RDS 網路架構：公有子網 vs 私有子網的差異 直接連線方式：當 RDS 設為 Publicly Accessible 時 SSH 隧道（SSH Tunneling）：透過 Bastion Host 連線私有 RDS 容器環境連線：在 Docker 和 Kubernetes Pod 中使用 psql psql 完整命令參考：從基礎查詢到進階操作 安全最佳實踐：如何保護資料庫連線與憑證 常見問題排查：連線失敗的系統化診斷方法 無論你是在本機開發、容器化部署、或是 Kubernetes 叢集中操作，這篇文章都能幫助你建立安全可靠的資料庫連線。\nAWS RDS 網路架構概覽 在開始連線之前，我們需要理解 AWS RDS 的網路架構。RDS 實例可以部署在不同的網路環境中，每種配置都有不同的連線方式和安全考量。\n公有子網 vs 私有子網 兩種部署方式的比較 特性 公有子網 (Publicly Accessible) 私有子網 (Private) 直接連線 ✅ 可以從網際網路直接連線 ❌ 無法直接連線 安全性 ⚠️ 較低，暴露在公網 ✅ 高，完全隔離 連線方式 psql 直連 需要 Bastion Host / VPN 適用場景 開發測試環境 生產環境（推薦） 成本 RDS 費用 RDS + Bastion Host 費用 維護複雜度 低 中等（需管理 Bastion） 最佳實踐：\n✅ 生產環境：使用私有子網 + Bastion Host 或 VPN ✅ 開發環境：可使用公有子網，但必須嚴格限制 Security Group ❌ 絕對避免：生產環境使用 Publicly Accessible + 0.0.0.0/0 (完全開放) 連線方式一：直接連線（公有子網） 當 RDS 實例設定為 Publicly Accessible 時，可以直接從網際網路連線。這是最簡單的方式，但也是最不安全的。\n完整連線流程 步驟 1：安裝 PostgreSQL Client 不同作業系統的安裝方式：\n# macOS (使用 Homebrew) brew install postgresql # Ubuntu/Debian sudo apt-get update sudo apt-get install postgresql-client # RHEL/CentOS/Amazon Linux sudo yum install postgresql # Windows (使用 Chocolatey) choco install postgresql # 驗證安裝 psql --version # 應該顯示：psql (PostgreSQL) 15.x 步驟 2：取得 RDS 連線資訊 在 AWS Console 中取得以下資訊：\nEndpoint（終端節點）：\nAWS Console \u0026gt; RDS \u0026gt; Databases \u0026gt; 選擇你的資料庫 在 \u0026ldquo;Connectivity \u0026amp; security\u0026rdquo; 頁籤中找到 \u0026ldquo;Endpoint\u0026rdquo; 格式：yourdb.xxx.ap-east-1.rds.amazonaws.com Port（埠號）：\n預設為 5432 可在同一頁面確認 Master username（主帳號）：\n在 \u0026ldquo;Configuration\u0026rdquo; 頁籤中找到 預設通常是 postgres Database name（資料庫名稱）：\n建立時指定的初始資料庫名稱 預設為 postgres 密碼：\n建立時設定的密碼 或從 AWS Secrets Manager 中取得 步驟 3：設定 Security Group 這是最關鍵的步驟！Security Group 就像是 RDS 的防火牆。\nAWS Console 操作步驟：\nAWS Console \u0026gt; RDS \u0026gt; Databases \u0026gt; 選擇你的資料庫 點擊 \u0026ldquo;VPC security groups\u0026rdquo; 連結 選擇 \u0026ldquo;Inbound rules\u0026rdquo; 頁籤 點擊 \u0026ldquo;Edit inbound rules\u0026rdquo; 新增規則： Type: PostgreSQL Protocol: TCP Port range: 5432 Source: My IP (AWS 會自動偵測你的 IP) Description: Dev access from my IP 點擊 \u0026ldquo;Save rules\u0026rdquo; 步驟 4：連線到 RDS 基本連線命令：\npsql \\ -h yourdb.xxx.ap-east-1.rds.amazonaws.com \\ -p 5432 \\ -U postgres \\ -d postgres # 參數說明： # -h: RDS Endpoint (host) # -p: 埠號 (port) # -U: 使用者名稱 (username) # -d: 資料庫名稱 (database) 執行後會提示輸入密碼：\nPassword for user postgres: 成功後會看到：\npsql (15.4) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) Type \u0026#34;help\u0026#34; for help. postgres=\u0026gt; 使用環境變數簡化連線 為了避免每次都輸入密碼，可以使用環境變數：\n# 設定環境變數 export PGHOST=yourdb.xxx.ap-east-1.rds.amazonaws.com export PGPORT=5432 export PGUSER=postgres export PGDATABASE=postgres export PGPASSWORD=your-password # ⚠️ 不安全，見下方說明 # 現在可以直接執行 psql psql 更安全的做法：使用 .pgpass 檔案\n# 建立 ~/.pgpass 檔案 echo \u0026#34;yourdb.xxx.ap-east-1.rds.amazonaws.com:5432:postgres:postgres:your-password\u0026#34; \u0026gt; ~/.pgpass # 設定權限（必須是 0600，否則 psql 會拒絕使用） chmod 0600 ~/.pgpass # 現在 psql 會自動讀取密碼 psql -h yourdb.xxx.ap-east-1.rds.amazonaws.com -U postgres -d postgres .pgpass 檔案格式：\nhostname:port:database:username:password 可以使用萬用字元：\n# 允許任何資料庫 yourdb.xxx.ap-east-1.rds.amazonaws.com:5432:*:postgres:your-password # 允許任何 RDS 實例 *.ap-east-1.rds.amazonaws.com:5432:*:postgres:your-password 連線方式二：SSH 隧道（私有子網） 在生產環境中，RDS 通常部署在私有子網（Private Subnet）中，無法直接從網際網路連線。這時需要透過 Bastion Host（跳板機）建立 SSH 隧道。\nSSH 隧道運作原理 SSH 隧道（SSH Tunneling）也稱為 SSH Port Forwarding，它可以讓你透過 SSH 連線，將本機的某個埠號轉發到遠端伺服器的另一個埠號。\n網路拓撲圖 步驟 1：準備 Bastion Host Bastion Host（跳板機）是部署在公有子網的 EC2 實例，用於安全地存取私有資源。\n建立 Bastion Host：\n啟動 EC2 實例：\nAMI: Amazon Linux 2 或 Ubuntu Instance Type: t3.micro（夠用） Network: 選擇 RDS 所在的 VPC Subnet: Public Subnet（必須有 Internet Gateway） Auto-assign Public IP: Enable Security Group: 建立新的 SG（見下方） 設定 Bastion Security Group：\nInbound Rules: - Type: SSH Protocol: TCP Port: 22 Source: My IP (限制只有你的 IP 可以連線) Outbound Rules: - Type: All traffic Destination: 0.0.0.0/0 設定 RDS Security Group：\nInbound Rules: - Type: PostgreSQL Protocol: TCP Port: 5432 Source: Bastion Security Group ID (sg-xxx) 這樣設定後，只有從 Bastion Host 發出的連線才能連到 RDS。\n下載 SSH 私鑰：\n建立 EC2 時會要求你建立或選擇 Key Pair 下載 .pem 檔案到本機（例如 ~/Downloads/bastion.pem） 移動到安全位置並設定權限： mkdir -p ~/.ssh mv ~/Downloads/bastion.pem ~/.ssh/ chmod 400 ~/.ssh/bastion.pem 步驟 2：測試 SSH 連線 先確認可以 SSH 到 Bastion Host：\nssh -i ~/.ssh/bastion.pem ec2-user@BASTION_PUBLIC_IP # Amazon Linux 使用 ec2-user # Ubuntu 使用 ubuntu 成功後會看到：\n[ec2-user@ip-10-0-1-50 ~]$ 這時你已經在 Bastion Host 上了。可以測試是否能連到 RDS：\n# 在 Bastion Host 上安裝 psql sudo yum install postgresql -y # Amazon Linux # 或 sudo apt-get install postgresql-client -y # Ubuntu # 測試連線 RDS psql -h yourdb.xxx.ap-east-1.rds.amazonaws.com -U postgres -d postgres # 如果成功，代表 Bastion → RDS 的網路路徑是通的 # 輸入 \\q 離開 psql # 輸入 exit 離開 Bastion 步驟 3：建立 SSH 隧道 回到本機，建立 SSH 隧道：\nssh -i ~/.ssh/bastion.pem \\ -L 5433:yourdb.xxx.ap-east-1.rds.amazonaws.com:5432 \\ -N \\ ec2-user@BASTION_PUBLIC_IP 參數說明：\n-i ~/.ssh/bastion.pem: 指定 SSH 私鑰 -L 5433:yourdb.xxx.ap-east-1.rds.amazonaws.com:5432: 本機埠號轉發 格式：本機埠號:目標主機:目標埠號 將本機的 5433 轉發到 RDS 的 5432 -N: 不執行遠端命令（只建立隧道） ec2-user@BASTION_PUBLIC_IP: 連線到 Bastion Host 執行後，終端機會卡住（這是正常的，表示隧道正在運作）。\n步驟 4：透過隧道連線 RDS 另開一個終端機視窗，執行：\npsql -h localhost -p 5433 -U postgres -d postgres # 或使用 127.0.0.1 psql -h 127.0.0.1 -p 5433 -U postgres -d postgres 重要：\nHost 必須是 localhost 或 127.0.0.1（不是 RDS endpoint） Port 必須是 5433（你在 -L 參數中設定的本機埠號） 成功後會看到：\npsql (15.4) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) Type \u0026#34;help\u0026#34; for help. postgres=\u0026gt; 背景執行 SSH 隧道 每次都要開一個終端機視窗很麻煩，可以將 SSH 隧道放到背景執行：\n# 方法 1: 使用 -f 參數 ssh -f -i ~/.ssh/bastion.pem \\ -L 5433:yourdb.xxx.ap-east-1.rds.amazonaws.com:5432 \\ -N \\ ec2-user@BASTION_PUBLIC_IP # -f: 在背景執行 # 方法 2: 使用 nohup nohup ssh -i ~/.ssh/bastion.pem \\ -L 5433:yourdb.xxx.ap-east-1.rds.amazonaws.com:5432 \\ -N \\ ec2-user@BASTION_PUBLIC_IP \u0026amp; # 檢查隧道是否運作 lsof -i :5433 # 應該會看到 ssh 程序正在監聽 5433 埠號 # 關閉隧道 kill $(lsof -t -i :5433) 使用 SSH Config 簡化設定 編輯 ~/.ssh/config：\n# Bastion Host Host bastion HostName BASTION_PUBLIC_IP User ec2-user IdentityFile ~/.ssh/bastion.pem ServerAliveInterval 60 ServerAliveCountMax 3 # RDS Tunnel Host rds-tunnel HostName localhost User postgres LocalForward 5433 yourdb.xxx.ap-east-1.rds.amazonaws.com:5432 ProxyJump bastion 設定後，連線變得更簡單：\n# 建立隧道 ssh -N rds-tunnel # 在另一個終端機連線 psql -h localhost -p 5433 -U postgres -d postgres 連線方式三：Docker 容器環境 在容器化環境中，應用程式通常運行在 Docker 容器內。我們需要從容器內部連線到 RDS。\nDocker 網路架構 情境 1：從現有容器連線 如果你的應用容器已經在運行，可以直接進入容器執行 psql：\n# 列出所有運行中的容器 docker ps # 輸出範例： # CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES # abc123def456 node:18 \u0026#34;node app\u0026#34; 2 hours ago Up 2 hours app-server # 進入容器（使用 bash） docker exec -it app-server bash # 或使用 sh（如果是 Alpine Linux） docker exec -it app-server sh # 在容器內安裝 psql（如果尚未安裝） # Debian/Ubuntu 基底 apt-get update \u0026amp;\u0026amp; apt-get install -y postgresql-client # Alpine Linux 基底 apk add --no-cache postgresql-client # 連線到 RDS psql -h yourdb.xxx.ap-east-1.rds.amazonaws.com -U postgres -d postgres 情境 2：啟動專用的 psql 客戶端容器 如果只是想快速連線查詢，可以使用官方 PostgreSQL 映像檔：\n# 方法 1: 互動模式（推薦用於臨時查詢） docker run -it --rm \\ --name postgres-client \\ -e PGPASSWORD=your-password \\ postgres:15 \\ psql -h yourdb.xxx.ap-east-1.rds.amazonaws.com \\ -U postgres \\ -d postgres # 參數說明： # -it: 互動模式 # --rm: 容器結束後自動刪除 # --name: 容器名稱 # -e PGPASSWORD: 設定密碼環境變數（避免每次輸入） # postgres:15: 使用 PostgreSQL 15 映像檔 # psql ...: 容器啟動後執行的命令 # 方法 2: 進入容器 shell docker run -it --rm postgres:15 bash # 在容器內執行 psql -h yourdb.xxx.ap-east-1.rds.amazonaws.com -U postgres -d postgres 情境 3：透過 Docker Compose 在 docker-compose.yml 中定義 psql 客戶端：\nversion: \u0026#39;3.8\u0026#39; services: # 你的應用服務 app: image: your-app:latest environment: - DATABASE_URL=postgresql://postgres:password@yourdb.xxx.ap-east-1.rds.amazonaws.com:5432/postgres networks: - app-network # psql 客戶端（用於管理） db-client: image: postgres:15 environment: - PGHOST=yourdb.xxx.ap-east-1.rds.amazonaws.com - PGPORT=5432 - PGUSER=postgres - PGPASSWORD=your-password - PGDATABASE=postgres networks: - app-network # 保持容器運行 command: tail -f /dev/null # 或使用 stdin_open 和 tty stdin_open: true tty: true networks: app-network: driver: bridge 使用方式：\n# 啟動所有服務 docker-compose up -d # 進入 db-client 容器 docker-compose exec db-client bash # 在容器內直接執行 psql（環境變數已設定） psql # 或從容器外直接執行 SQL docker-compose exec db-client psql -c \u0026#34;SELECT version();\u0026#34; # 停止服務 docker-compose down 從容器連線的安全考量 連線方式四：Kubernetes Pod 環境 在 Kubernetes 叢集中（例如 AWS EKS），Pod 與 RDS 之間的連線涉及更複雜的網路架構。\nKubernetes 與 RDS 網路架構 關鍵概念：Pod 到 RDS 的連線路徑 Pod Network（CNI）：\nEKS 使用 VPC CNI，Pod 獲得 VPC 子網的 IP 位址 Pod IP 範圍通常與 Node 所在的子網相同 Security Group 設定：\nRDS Security Group 必須允許來自 Node Security Group 的連線 因為 Pod 的流量會透過 Node 轉發（SNAT） DNS 解析：\nPod 內可以直接使用 RDS Endpoint CoreDNS 會將其解析為 RDS 的私有 IP 情境 1：從現有 Pod 連線 # 列出所有 Pod kubectl get pods -n default # 輸出範例： # NAME READY STATUS RESTARTS AGE # app-deployment-abc123 1/1 Running 0 2h # redis-xyz789 1/1 Running 0 5h # 進入 Pod（執行 bash） kubectl exec -it app-deployment-abc123 -n default -- bash # 或使用 sh kubectl exec -it app-deployment-abc123 -n default -- sh # 在 Pod 內安裝 psql（如果尚未安裝） apt-get update \u0026amp;\u0026amp; apt-get install -y postgresql-client # 連線到 RDS psql -h yourdb.xxx.ap-east-1.rds.amazonaws.com -U postgres -d postgres # 離開 Pod exit 情境 2：直接從 Pod 執行單一命令 不需要進入 Pod，直接執行 psql 命令：\n# 執行單一 SQL 查詢 kubectl exec -it app-deployment-abc123 -n default -- \\ psql -h yourdb.xxx.ap-east-1.rds.amazonaws.com \\ -U postgres \\ -d postgres \\ -c \u0026#34;SELECT version();\u0026#34; # 執行多個命令 kubectl exec -it app-deployment-abc123 -n default -- \\ psql -h yourdb.xxx.ap-east-1.rds.amazonaws.com \\ -U postgres \\ -d postgres \\ -c \u0026#34;SELECT * FROM users LIMIT 5;\u0026#34; 情境 3：部署專用的 psql 客戶端 Pod 建立一個臨時的 PostgreSQL 客戶端 Pod：\nkubectl run psql-client \\ --rm -i --tty \\ --image=postgres:15 \\ --namespace=default \\ --env=\u0026#34;PGHOST=yourdb.xxx.ap-east-1.rds.amazonaws.com\u0026#34; \\ --env=\u0026#34;PGPORT=5432\u0026#34; \\ --env=\u0026#34;PGUSER=postgres\u0026#34; \\ --env=\u0026#34;PGPASSWORD=your-password\u0026#34; \\ --env=\u0026#34;PGDATABASE=postgres\u0026#34; \\ --restart=Never \\ --command -- psql # 參數說明： # --rm: Pod 結束後自動刪除 # -i --tty: 互動模式 # --image: 使用的容器映像 # --env: 設定環境變數 # --restart=Never: 不自動重啟 # --command -- psql: 執行 psql 命令 成功後會直接進入 psql 互動介面：\npsql (15.4) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) Type \u0026#34;help\u0026#34; for help. postgres=\u0026gt; 離開 psql 後，Pod 會自動刪除。\n情境 4：使用 Kubernetes Deployment 建立一個長期運行的 psql 客戶端 Deployment：\n# psql-client-deployment.yaml apiVersion: v1 kind: Secret metadata: name: rds-credentials namespace: default type: Opaque stringData: PGHOST: \u0026#34;yourdb.xxx.ap-east-1.rds.amazonaws.com\u0026#34; PGPORT: \u0026#34;5432\u0026#34; PGUSER: \u0026#34;postgres\u0026#34; PGPASSWORD: \u0026#34;your-password\u0026#34; PGDATABASE: \u0026#34;postgres\u0026#34; --- apiVersion: apps/v1 kind: Deployment metadata: name: psql-client namespace: default spec: replicas: 1 selector: matchLabels: app: psql-client template: metadata: labels: app: psql-client spec: containers: - name: postgres-client image: postgres:15 envFrom: - secretRef: name: rds-credentials command: - sh - -c - | # 安裝額外工具 apt-get update \u0026amp;\u0026amp; apt-get install -y vim curl # 保持容器運行 tail -f /dev/null resources: requests: memory: \u0026#34;128Mi\u0026#34; cpu: \u0026#34;100m\u0026#34; limits: memory: \u0026#34;256Mi\u0026#34; cpu: \u0026#34;200m\u0026#34; 部署並使用：\n# 部署 kubectl apply -f psql-client-deployment.yaml # 等待 Pod 就緒 kubectl wait --for=condition=ready pod -l app=psql-client --timeout=60s # 進入 Pod kubectl exec -it deployment/psql-client -- bash # 在 Pod 內執行 psql（環境變數已設定） psql # 或從外部直接執行查詢 kubectl exec deployment/psql-client -- psql -c \u0026#34;SELECT current_database();\u0026#34; # 刪除部署 kubectl delete -f psql-client-deployment.yaml 使用 Kubernetes Jobs 執行一次性查詢 如果只需要執行一次性的資料庫操作（如資料遷移），可以使用 Job：\n# db-migration-job.yaml apiVersion: batch/v1 kind: Job metadata: name: db-migration namespace: default spec: template: spec: restartPolicy: Never containers: - name: migration image: postgres:15 env: - name: PGHOST valueFrom: secretKeyRef: name: rds-credentials key: PGHOST - name: PGUSER valueFrom: secretKeyRef: name: rds-credentials key: PGUSER - name: PGPASSWORD valueFrom: secretKeyRef: name: rds-credentials key: PGPASSWORD - name: PGDATABASE valueFrom: secretKeyRef: name: rds-credentials key: PGDATABASE command: - sh - -c - | # 執行資料遷移 SQL psql \u0026lt;\u0026lt;EOF CREATE TABLE IF NOT EXISTS migrations ( id SERIAL PRIMARY KEY, version VARCHAR(50) NOT NULL, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); INSERT INTO migrations (version) VALUES (\u0026#39;v1.0.0\u0026#39;); EOF echo \u0026#34;Migration completed successfully\u0026#34; backoffLimit: 3 執行 Job：\n# 建立 Job kubectl apply -f db-migration-job.yaml # 查看 Job 狀態 kubectl get jobs # 查看 Job 日誌 kubectl logs job/db-migration # 刪除 Job kubectl delete job db-migration psql 互動介面完整命令參考 進入 psql 後，你會看到 postgres=\u0026gt; 或 dbname=\u0026gt; 提示符。以下是完整的命令參考。\nMeta-Commands（以 \\ 開頭的命令） 這些命令不是 SQL，而是 psql 特有的管理命令：\n資料庫管理命令 命令 說明 範例 \\l 或 \\list 列出所有資料庫 \\l \\l+ 列出資料庫（包含大小和描述） \\l+ \\c dbname 切換到指定資料庫 \\c my_database \\conninfo 顯示當前連線資訊 \\conninfo \\du 列出所有使用者（角色） \\du \\du+ 列出使用者詳細資訊 \\du+ 範例輸出：\n-- 列出所有資料庫 postgres=\u0026gt; \\l List of databases Name | Owner | Encoding | Collate | Ctype | Access privileges -----------+----------+----------+-------------+-------------+----------------------- my_db | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 | postgres | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 | template0 | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 | =c/postgres + | | | | | postgres=CTc/postgres template1 | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 | =c/postgres + | | | | | postgres=CTc/postgres (4 rows) -- 切換資料庫 postgres=\u0026gt; \\c my_db You are now connected to database \u0026#34;my_db\u0026#34; as user \u0026#34;postgres\u0026#34;. my_db=\u0026gt; -- 顯示連線資訊 my_db=\u0026gt; \\conninfo You are connected to database \u0026#34;my_db\u0026#34; as user \u0026#34;postgres\u0026#34; on host \u0026#34;yourdb.xxx.rds.amazonaws.com\u0026#34; at port \u0026#34;5432\u0026#34;. SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) Schema 和資料表管理 命令 說明 範例 \\dn 列出所有 schema \\dn \\dt 列出當前 schema 的資料表 \\dt \\dt *.* 列出所有 schema 的資料表 \\dt *.* \\dt public.* 列出 public schema 的資料表 \\dt public.* \\dt+ 列出資料表（包含大小） \\dt+ \\d tablename 描述資料表結構 \\d users \\d+ tablename 詳細描述資料表 \\d+ users \\di 列出索引 \\di \\dv 列出 views \\dv \\df 列出函式 \\df \\ds 列出 sequences \\ds 範例輸出：\n-- 列出資料表 my_db=\u0026gt; \\dt List of relations Schema | Name | Type | Owner --------+---------------+-------+---------- public | users | table | postgres public | posts | table | postgres public | comments | table | postgres (3 rows) -- 描述資料表結構 my_db=\u0026gt; \\d users Table \u0026#34;public.users\u0026#34; Column | Type | Collation | Nullable | Default ------------+-----------------------------+-----------+----------+----------------------------------- id | integer | | not null | nextval(\u0026#39;users_id_seq\u0026#39;::regclass) email | character varying(255) | | not null | name | character varying(100) | | | created_at | timestamp without time zone | | | CURRENT_TIMESTAMP Indexes: \u0026#34;users_pkey\u0026#34; PRIMARY KEY, btree (id) \u0026#34;users_email_key\u0026#34; UNIQUE CONSTRAINT, btree (email) Referenced by: TABLE \u0026#34;posts\u0026#34; CONSTRAINT \u0026#34;posts_user_id_fkey\u0026#34; FOREIGN KEY (user_id) REFERENCES users(id) 顯示模式控制 命令 說明 範例 \\x 切換擴展顯示模式（垂直顯示） \\x \\x auto 自動判斷使用哪種顯示模式 \\x auto \\pset format 設定輸出格式 \\pset format wrapped \\timing 開啟/關閉查詢時間顯示 \\timing 範例：\n-- 預設水平顯示 my_db=\u0026gt; SELECT * FROM users LIMIT 1; id | email | name | created_at ----+------------------+--------+-------------------- 1 | user@example.com | John | 2024-01-01 10:00:00 (1 row) -- 開啟擴展模式（垂直顯示） my_db=\u0026gt; \\x Expanded display is on. my_db=\u0026gt; SELECT * FROM users LIMIT 1; -[ RECORD 1 ]------------------------ id | 1 email | user@example.com name | John created_at | 2024-01-01 10:00:00 -- 關閉擴展模式 my_db=\u0026gt; \\x Expanded display is off. -- 開啟查詢時間顯示 my_db=\u0026gt; \\timing Timing is on. my_db=\u0026gt; SELECT COUNT(*) FROM users; count ------- 1000 (1 row) Time: 5.234 ms SQL 查詢命令 以下是常用的 SQL 查詢範例：\n-- 查看資料庫版本 SELECT version(); -- 查看當前資料庫 SELECT current_database(); -- 查看當前使用者 SELECT current_user; -- 查看所有資料表（SQL 方式） SELECT tablename FROM pg_tables WHERE schemaname = \u0026#39;public\u0026#39;; -- 查詢前 N 筆資料 SELECT * FROM users LIMIT 5; -- 查詢特定欄位 SELECT id, email, name FROM users; -- 條件查詢 SELECT * FROM users WHERE email = \u0026#39;user@example.com\u0026#39;; -- 模糊查詢 SELECT * FROM users WHERE name LIKE \u0026#39;John%\u0026#39;; -- 排序 SELECT * FROM users ORDER BY created_at DESC; -- 計數 SELECT COUNT(*) FROM users; -- 分組統計 SELECT DATE(created_at), COUNT(*) FROM users GROUP BY DATE(created_at) ORDER BY DATE(created_at) DESC; -- 查詢不重複值 SELECT DISTINCT email FROM users; -- JOIN 查詢 SELECT u.name, p.title FROM users u INNER JOIN posts p ON u.id = p.user_id LIMIT 10; 資料更新命令 -- 更新單一記錄 UPDATE users SET name = \u0026#39;John Doe\u0026#39; WHERE id = 1; -- 更新多筆記錄 UPDATE users SET email = LOWER(email) WHERE email LIKE \u0026#39;%@EXAMPLE.COM\u0026#39;; -- 批次更新 UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE created_at \u0026lt; \u0026#39;2024-01-01\u0026#39;; -- 刪除記錄 DELETE FROM users WHERE id = 999; -- 刪除符合條件的記錄 DELETE FROM users WHERE created_at \u0026lt; \u0026#39;2020-01-01\u0026#39;; -- 清空資料表（保留結構） TRUNCATE TABLE users; -- 插入記錄 INSERT INTO users (email, name) VALUES (\u0026#39;new@example.com\u0026#39;, \u0026#39;New User\u0026#39;); -- 插入多筆記錄 INSERT INTO users (email, name) VALUES (\u0026#39;user1@example.com\u0026#39;, \u0026#39;User 1\u0026#39;), (\u0026#39;user2@example.com\u0026#39;, \u0026#39;User 2\u0026#39;), (\u0026#39;user3@example.com\u0026#39;, \u0026#39;User 3\u0026#39;); -- 插入並回傳結果 INSERT INTO users (email, name) VALUES (\u0026#39;test@example.com\u0026#39;, \u0026#39;Test User\u0026#39;) RETURNING id, email; 交易控制 -- 開始交易 BEGIN; -- 執行多個操作 UPDATE users SET name = \u0026#39;Updated\u0026#39; WHERE id = 1; INSERT INTO audit_log (action, user_id) VALUES (\u0026#39;update\u0026#39;, 1); -- 提交交易 COMMIT; -- 或回滾交易 ROLLBACK; -- 設定儲存點 BEGIN; UPDATE users SET name = \u0026#39;First Update\u0026#39; WHERE id = 1; SAVEPOINT sp1; UPDATE users SET name = \u0026#39;Second Update\u0026#39; WHERE id = 2; -- 回滾到儲存點 ROLLBACK TO SAVEPOINT sp1; -- 只有第一個更新會生效 COMMIT; 進階查詢技巧 -- 查看資料表大小 SELECT schemaname, tablename, pg_size_pretty(pg_total_relation_size(schemaname||\u0026#39;.\u0026#39;||tablename)) AS size FROM pg_tables WHERE schemaname = \u0026#39;public\u0026#39; ORDER BY pg_total_relation_size(schemaname||\u0026#39;.\u0026#39;||tablename) DESC; -- 查看資料庫大小 SELECT datname, pg_size_pretty(pg_database_size(datname)) AS size FROM pg_database ORDER BY pg_database_size(datname) DESC; -- 查看正在執行的查詢 SELECT pid, usename, application_name, client_addr, state, query, query_start FROM pg_stat_activity WHERE state != \u0026#39;idle\u0026#39; ORDER BY query_start; -- 終止慢查詢 SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state = \u0026#39;active\u0026#39; AND query_start \u0026lt; now() - interval \u0026#39;5 minutes\u0026#39;; -- 查看索引使用情況 SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read, idx_tup_fetch FROM pg_stat_user_indexes ORDER BY idx_scan DESC; -- 查看未使用的索引 SELECT schemaname, tablename, indexname FROM pg_stat_user_indexes WHERE idx_scan = 0 AND indexname NOT LIKE \u0026#39;%_pkey\u0026#39;; 匯出與匯入 -- 匯出查詢結果到 CSV \\copy (SELECT * FROM users) TO \u0026#39;/tmp/users.csv\u0026#39; WITH CSV HEADER; -- 從 CSV 匯入資料 \\copy users FROM \u0026#39;/tmp/users.csv\u0026#39; WITH CSV HEADER; -- 匯出整個資料表 \\copy users TO \u0026#39;/tmp/users_backup.csv\u0026#39; WITH CSV HEADER; 注意：\\copy 在客戶端執行，檔案路徑是相對於執行 psql 的機器。COPY（沒有反斜線）在伺服器端執行，檔案路徑是相對於資料庫伺服器。\n其他實用命令 命令 說明 範例 \\? 顯示所有 backslash 命令 \\? \\h SQL 顯示 SQL 語法說明 \\h SELECT \\q 離開 psql \\q \\e 開啟編輯器編輯最後的命令 \\e \\i filename 執行檔案中的 SQL \\i /path/to/script.sql \\o filename 將輸出重導向到檔案 \\o /tmp/output.txt \\! command 執行 shell 命令 \\! ls -la \\password 更改密碼 \\password postgres \\echo text 顯示文字 \\echo 'Hello World' 安全最佳實踐 密碼管理 1. 使用 IAM 認證（AWS RDS） AWS RDS 支援使用 IAM 角色進行認證，無需管理密碼：\n# 啟用 RDS IAM 認證 # AWS Console \u0026gt; RDS \u0026gt; Modify \u0026gt; Database authentication \u0026gt; Enable IAM authentication # 在 EC2/EKS 上使用 IAM Role 產生臨時 token TOKEN=$(aws rds generate-db-auth-token \\ --hostname yourdb.xxx.ap-east-1.rds.amazonaws.com \\ --port 5432 \\ --username iamuser \\ --region ap-east-1) # 使用 token 連線（15 分鐘有效） psql \u0026#34;host=yourdb.xxx.ap-east-1.rds.amazonaws.com \\ port=5432 \\ dbname=postgres \\ user=iamuser \\ password=$TOKEN \\ sslmode=require\u0026#34; 2. 強制使用 SSL 連線 -- 檢查是否使用 SSL SELECT ssl_is_used(); -- 查看 SSL 版本 \\conninfo -- 強制要求 SSL（在 psql 連線字串中） psql \u0026#34;host=yourdb.xxx.rds.amazonaws.com \\ port=5432 \\ dbname=postgres \\ user=postgres \\ sslmode=require\u0026#34; -- sslmode 選項： -- disable: 不使用 SSL -- allow: 嘗試 SSL，失敗則降級 -- prefer: 優先使用 SSL（預設） -- require: 強制 SSL，不驗證憑證 -- verify-ca: 強制 SSL，驗證憑證 -- verify-full: 強制 SSL，驗證憑證和主機名 3. 最小權限原則 -- 建立唯讀使用者 CREATE ROLE readonly WITH LOGIN PASSWORD \u0026#39;secure-password\u0026#39;; -- 授予連線權限 GRANT CONNECT ON DATABASE my_db TO readonly; -- 授予 schema 使用權限 GRANT USAGE ON SCHEMA public TO readonly; -- 授予所有資料表的 SELECT 權限 GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly; -- 對未來建立的資料表也生效 ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO readonly; -- 建立應用程式使用者（讀寫權限，但不能刪除資料表） CREATE ROLE appuser WITH LOGIN PASSWORD \u0026#39;secure-password\u0026#39;; GRANT CONNECT ON DATABASE my_db TO appuser; GRANT USAGE ON SCHEMA public TO appuser; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO appuser; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO appuser; 4. 審計與監控 -- 啟用查詢日誌（AWS RDS Parameter Group） -- log_statement = \u0026#39;all\u0026#39; -- 記錄所有語句（效能影響大） -- log_statement = \u0026#39;ddl\u0026#39; -- 只記錄 DDL（CREATE, ALTER, DROP） -- log_statement = \u0026#39;mod\u0026#39; -- 記錄資料修改（INSERT, UPDATE, DELETE） -- 查看最近的連線 SELECT usename, application_name, client_addr, backend_start, state FROM pg_stat_activity ORDER BY backend_start DESC; -- 查看失敗的連線嘗試（需啟用 log_connections） -- 在 CloudWatch Logs 中查看： -- /aws/rds/instance/your-db/postgresql 5. 網路隔離 最佳實踐檢查清單：\n✅ RDS 部署在私有子網 ✅ Publicly Accessible 設為 No ✅ Security Group 限制來源 IP 或 Security Group ✅ 使用 Bastion Host 或 VPN 連線 ✅ 啟用 SSL 連線（sslmode=require） ✅ 使用 IAM 認證（如可行） ✅ 定期輪換密碼 ✅ 使用最小權限原則 ✅ 啟用自動備份 ✅ 啟用 Multi-AZ（高可用性） ✅ 啟用 CloudWatch 監控 ✅ 啟用 Performance Insights 常見問題排查 問題 1：連線逾時（Connection Timeout） 診斷命令：\n# 1. 測試網路連通性 # 從本機測試 telnet yourdb.xxx.ap-east-1.rds.amazonaws.com 5432 # 或使用 nc nc -zv yourdb.xxx.ap-east-1.rds.amazonaws.com 5432 # 成功會顯示： # Connection to yourdb.xxx.ap-east-1.rds.amazonaws.com 5432 port [tcp/postgresql] succeeded! # 2. DNS 解析測試 nslookup yourdb.xxx.ap-east-1.rds.amazonaws.com dig yourdb.xxx.ap-east-1.rds.amazonaws.com # 3. 路由追蹤 traceroute yourdb.xxx.ap-east-1.rds.amazonaws.com # 4. 檢查本機防火牆（macOS） sudo pfctl -sr | grep 5432 # 5. 從 Bastion Host 測試 ssh -i ~/.ssh/bastion.pem ec2-user@BASTION_IP telnet yourdb.xxx.ap-east-1.rds.amazonaws.com 5432 問題 2：認證失敗（Authentication Failed） psql: error: connection to server at \u0026#34;yourdb.xxx.rds.amazonaws.com\u0026#34; (10.0.2.100), port 5432 failed: FATAL: password authentication failed for user \u0026#34;postgres\u0026#34; 可能原因與解決方法：\n# 1. 密碼錯誤 # 從 AWS Secrets Manager 取得正確密碼 aws secretsmanager get-secret-value \\ --secret-id rds-db-credentials \\ --query SecretString \\ --output text | jq -r .password # 2. 使用者不存在 # 連線為 master user，建立新使用者 psql -h ... -U postgres -c \u0026#34;CREATE USER myuser WITH PASSWORD \u0026#39;mypassword\u0026#39;;\u0026#34; # 3. 資料庫不存在 # 列出所有資料庫 psql -h ... -U postgres -l # 建立資料庫 psql -h ... -U postgres -c \u0026#34;CREATE DATABASE my_db;\u0026#34; # 4. IP 位址被拒絕（pg_hba.conf） # RDS 的 pg_hba.conf 由 AWS 管理，但可以透過 Parameter Group 調整 # 檢查連線是否使用 SSL psql \u0026#34;host=... sslmode=require user=postgres dbname=postgres\u0026#34; 問題 3：SSL 連線問題 psql: error: connection to server at \u0026#34;yourdb.xxx.rds.amazonaws.com\u0026#34; failed: SSL connection has been closed unexpectedly 解決方法：\n# 1. 下載 RDS SSL 憑證 wget https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem # 2. 使用憑證連線 psql \u0026#34;host=yourdb.xxx.rds.amazonaws.com \\ port=5432 \\ dbname=postgres \\ user=postgres \\ sslmode=verify-full \\ sslrootcert=global-bundle.pem\u0026#34; # 3. 或降低 SSL 要求（不建議用於生產環境） psql \u0026#34;host=yourdb.xxx.rds.amazonaws.com \\ port=5432 \\ dbname=postgres \\ user=postgres \\ sslmode=require\u0026#34; 問題 4：Kubernetes Pod 無法連線 # 1. 檢查 Pod 是否可以解析 RDS Endpoint kubectl exec -it pod-name -- nslookup yourdb.xxx.ap-east-1.rds.amazonaws.com # 2. 檢查 Pod 是否可以連通 RDS 埠號 kubectl exec -it pod-name -- nc -zv yourdb.xxx.ap-east-1.rds.amazonaws.com 5432 # 3. 檢查 Node Security Group # AWS Console \u0026gt; EC2 \u0026gt; Security Groups # 找到 EKS Node 的 SG，記下 SG ID (例如 sg-0123456789abcdef0) # 4. 確認 RDS Security Group 允許 Node SG # AWS Console \u0026gt; RDS \u0026gt; Databases \u0026gt; Your DB \u0026gt; Connectivity \u0026amp; security # VPC security groups \u0026gt; Edit inbound rules # 新增規則： # Type: PostgreSQL # Source: sg-0123456789abcdef0 (Node SG) # 5. 檢查環境變數是否正確設定 kubectl exec -it pod-name -- env | grep PG 問題 5：連線數過多 psql: error: FATAL: sorry, too many clients already 解決方法：\n-- 查看當前連線數 SELECT COUNT(*) FROM pg_stat_activity; -- 查看最大連線數限制 SHOW max_connections; -- 查看各使用者的連線數 SELECT usename, COUNT(*) as connection_count FROM pg_stat_activity GROUP BY usename ORDER BY connection_count DESC; -- 終止閒置連線 SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state = \u0026#39;idle\u0026#39; AND state_change \u0026lt; now() - interval \u0026#39;10 minutes\u0026#39;; -- 增加最大連線數（需修改 RDS Parameter Group） -- AWS Console \u0026gt; RDS \u0026gt; Parameter groups \u0026gt; Edit parameters -- 找到 max_connections，修改為更大的值（例如 200） -- 套用後需要重啟 RDS 實例 效能優化技巧 連線池（Connection Pooling） 對於高流量應用，使用連線池可以大幅提升效能：\n# 安裝 PgBouncer（在應用伺服器或 Bastion Host） sudo apt-get install pgbouncer # 設定 /etc/pgbouncer/pgbouncer.ini [databases] my_db = host=yourdb.xxx.rds.amazonaws.com port=5432 dbname=my_db [pgbouncer] listen_addr = 0.0.0.0 listen_port = 6432 auth_type = md5 auth_file = /etc/pgbouncer/userlist.txt pool_mode = transaction max_client_conn = 1000 default_pool_size = 20 # 應用程式連到 PgBouncer，而非直接連 RDS psql -h pgbouncer-host -p 6432 -U postgres -d my_db 查詢優化 -- 使用 EXPLAIN ANALYZE 分析查詢 EXPLAIN ANALYZE SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id WHERE u.email = \u0026#39;user@example.com\u0026#39;; -- 建立索引加速查詢 CREATE INDEX idx_users_email ON users(email); CREATE INDEX idx_posts_user_id ON posts(user_id); -- 檢視慢查詢（需啟用 log_min_duration_statement） -- 在 RDS Parameter Group 設定： -- log_min_duration_statement = 1000 （記錄超過 1 秒的查詢） -- 在 CloudWatch Logs 中查看慢查詢日誌 總結 本文深入探討了如何在各種環境中連線 AWS RDS PostgreSQL：\n直接連線：適用於開發測試，需正確設定 Security Group SSH 隧道：生產環境最佳實踐，透過 Bastion Host 安全連線 Docker 容器：使用官方 PostgreSQL 映像檔或在應用容器中安裝 psql Kubernetes Pod：理解 Pod 網路與 Node Security Group 的關係 psql 命令：完整的互動介面命令參考，從基礎到進階 安全實踐：IAM 認證、SSL 連線、最小權限、網路隔離 問題排查：系統化診斷連線、認證、網路問題 關鍵要點：\n✅ 生產環境永遠使用私有子網 + Bastion Host ✅ 使用 .pgpass 檔案或 Secret 管理服務儲存密碼 ✅ 強制使用 SSL 連線（sslmode=require） ✅ 遵循最小權限原則，不同用途使用不同帳號 ✅ 啟用監控與審計，定期檢視連線與查詢日誌 ✅ 使用連線池減少資料庫負擔 掌握這些技巧後，你就能在任何環境中安全、高效地操作 AWS RDS PostgreSQL！\n參考資源 AWS RDS PostgreSQL 官方文件 PostgreSQL 官方文件 psql 命令參考 AWS RDS Security Best Practices SSH Tunneling 教學 Kubernetes Networking ","permalink":"https://peter-notes.com/posts/psql-connect-aws-rds-k8s/","summary":"\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e在現代雲端架構中，資料庫通常部署在受保護的私有網路環境（Private Subnet）中，以提升安全性。AWS RDS（Relational Database Service）作為主流的托管資料庫服務，提供了多種連線方式，但對於初學者來說，如何在不同環境（本機、Docker、Kubernetes）中正確連線到 RDS 往往充滿挑戰。\u003c/p\u003e\n\u003cp\u003e這篇文章將深入探討：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eAWS RDS 網路架構\u003c/strong\u003e：公有子網 vs 私有子網的差異\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e直接連線方式\u003c/strong\u003e：當 RDS 設為 Publicly Accessible 時\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSSH 隧道（SSH Tunneling）\u003c/strong\u003e：透過 Bastion Host 連線私有 RDS\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e容器環境連線\u003c/strong\u003e：在 Docker 和 Kubernetes Pod 中使用 psql\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003epsql 完整命令參考\u003c/strong\u003e：從基礎查詢到進階操作\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e安全最佳實踐\u003c/strong\u003e：如何保護資料庫連線與憑證\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e常見問題排查\u003c/strong\u003e：連線失敗的系統化診斷方法\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e無論你是在本機開發、容器化部署、或是 Kubernetes 叢集中操作，這篇文章都能幫助你建立安全可靠的資料庫連線。\u003c/p\u003e\n\u003ch2 id=\"aws-rds-網路架構概覽\"\u003eAWS RDS 網路架構概覽\u003c/h2\u003e\n\u003cp\u003e在開始連線之前，我們需要理解 AWS RDS 的網路架構。RDS 實例可以部署在不同的網路環境中，每種配置都有不同的連線方式和安全考量。\u003c/p\u003e\n\u003ch3 id=\"公有子網-vs-私有子網\"\u003e公有子網 vs 私有子網\u003c/h3\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/2025-05-26-如何使用-psql-連線-aws-rds-postgresql-並在容器與-pod-中操作-diagram-6dbd07f6.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003ch3 id=\"兩種部署方式的比較\"\u003e兩種部署方式的比較\u003c/h3\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e特性\u003c/th\u003e\n          \u003cth\u003e公有子網 (Publicly Accessible)\u003c/th\u003e\n          \u003cth\u003e私有子網 (Private)\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e直接連線\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e✅ 可以從網際網路直接連線\u003c/td\u003e\n          \u003ctd\u003e❌ 無法直接連線\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e安全性\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e⚠️ 較低，暴露在公網\u003c/td\u003e\n          \u003ctd\u003e✅ 高，完全隔離\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e連線方式\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003epsql 直連\u003c/td\u003e\n          \u003ctd\u003e需要 Bastion Host / VPN\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e適用場景\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e開發測試環境\u003c/td\u003e\n          \u003ctd\u003e生產環境（推薦）\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e成本\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003eRDS 費用\u003c/td\u003e\n          \u003ctd\u003eRDS + Bastion Host 費用\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003e維護複雜度\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e低\u003c/td\u003e\n          \u003ctd\u003e中等（需管理 Bastion）\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e\u003cstrong\u003e最佳實踐\u003c/strong\u003e：\u003c/p\u003e","title":"如何使用 psql 連線 AWS RDS PostgreSQL 並在容器與 Pod 中操作"},{"content":"前言 在現代 Web 應用開發中，提供第三方登入（Social Login）已經成為標準配備。相比傳統的帳號密碼註冊流程，使用 Google、Facebook、GitHub 等服務登入不僅能降低使用者註冊門檻，還能提升安全性（由大廠處理密碼儲存與驗證）。\n當我們決定為 Strapi CMS 後台加入 Google OAuth 登入時，原本以為只是個簡單的設定任務：\n在 Google Cloud Console 建立 OAuth 2.0 憑證 在 Strapi 填入 Client ID 和 Client Secret 點擊「Login with Google」按鈕，完成！ 但現實總是更複雜。當應用部署到 Kubernetes 叢集後，我們遇到了一個令人困惑的錯誤訊息：\nError: Cannot send secure cookie over unencrypted connection 這個錯誤訊息背後，牽涉到 HTTP/HTTPS 協定、Proxy Trust 機制、Kubernetes Ingress 架構、以及瀏覽器 Cookie 安全策略等多層知識。這篇文章將完整記錄我如何一步步拆解問題、理解根本原因、並最終在生產環境中實現安全可靠的 Google 登入功能。\nOAuth 2.0 授權碼流程基礎 在深入問題之前，我們先理解 Google OAuth 登入的完整流程。OAuth 2.0 提供了多種授權模式（Grant Types），而 Web 應用最常使用的是「授權碼模式（Authorization Code Flow）」。\n這個流程中有幾個關鍵點：\n授權碼（Authorization Code）：一次性使用的臨時憑證，只能用來換取 Access Token 重導向 URI（Redirect URI）：必須與 Google Console 設定完全一致，包括 protocol（http/https）、domain、path Secure Cookie：為了安全性，Session Cookie 通常會設定 Secure 屬性，要求只能在 HTTPS 連線中傳輸 問題發生點：在第 13 步，Strapi 嘗試設定 Secure Cookie 時，如果它認為當前連線是 HTTP，就會拋出錯誤 Strapi Google Provider 內部架構 Strapi 使用了 @strapi/plugin-users-permissions 來處理第三方登入。讓我們看看 Google Provider 的內部運作流程：\n從這個流程圖可以看出，問題發生在最後階段：Strapi 在設定 Session Cookie 時，會檢查當前連線是否為 HTTPS。\n但這裡有個關鍵問題：在 Kubernetes 環境中，Strapi Pod 收到的請求可能已經被 Ingress Controller 解密，從 Pod 的角度看，連線是 HTTP。\nKubernetes 網路層級分析 在 Kubernetes 部署環境中，使用者的 HTTPS 請求會經過多層轉發才到達 Strapi Pod。讓我們看看完整的網路路徑：\n這個架構帶來的問題是：\n使用者端：確實是透過 HTTPS 連線（https://cms.example.com） Load Balancer / Ingress：在這層進行 TLS 終止（TLS Termination），解密 HTTPS 流量 Strapi Pod：收到的是已解密的 HTTP 請求 這種架構稱為「TLS 終止代理（TLS Termination Proxy）」，是 Kubernetes 環境中的標準做法。但這也導致 Strapi 無法直接判斷「使用者原始請求是否為 HTTPS」。\nX-Forwarded-* Headers 機制 為了解決這個問題，Reverse Proxy（如 Nginx Ingress）會在轉發請求時加入特殊的 HTTP Headers，告訴後端應用「原始請求的資訊」：\n常見的 Forwarded Headers 包括：\nHeader 說明 範例值 X-Forwarded-Proto 原始協定 https 或 http X-Forwarded-Host 原始 Host cms.example.com X-Forwarded-Port 原始埠號 443 或 80 X-Forwarded-For 原始 IP 位址（可能有多個） 1.2.3.4, 5.6.7.8 X-Real-IP 真實 IP（通常是第一個） 1.2.3.4 Forwarded RFC 7239 標準格式 for=1.2.3.4;proto=https;host=cms.example.com 但這裡有個安全問題：這些 Headers 可以被偽造。如果 Strapi 無條件信任這些 Headers，惡意使用者可以自己加入 X-Forwarded-Proto: https 來欺騙應用。\n因此，Strapi（底層使用 Koa.js）預設不信任 Forwarded Headers，除非明確啟用 Proxy Trust Mode。\n問題拆解：錯誤發生的完整時序 現在讓我們完整重現錯誤發生的過程：\n關鍵點在於第 11 步：\nStrapi 使用 Koa.js 的 ctx.protocol 來判斷協定 但因為 Proxy Trust 關閉，Koa 不讀取 X-Forwarded-Proto 所以 ctx.protocol 回傳 'http'（因為 Pod 收到的確實是 HTTP） 當嘗試設定 Secure Cookie 時，Koa 檢查到協定不是 https，拋出錯誤 Cookie 安全屬性詳解 在解決問題之前，我們需要理解 Cookie 的安全屬性。現代瀏覽器支援多種 Cookie 屬性來提升安全性：\nCookie 屬性說明 屬性 說明 安全影響 Secure 只在 HTTPS 連線中傳輸 ✅ 防止中間人攻擊（MITM）竊取 Cookie HttpOnly 無法透過 JavaScript 讀取 ✅ 防止 XSS 攻擊竊取 Cookie SameSite 限制跨站請求是否帶 Cookie ✅ 防止 CSRF 攻擊 ├─ Strict 完全禁止跨站請求帶 Cookie 最嚴格，但可能影響使用者體驗 ├─ Lax 允許 GET 導航請求帶 Cookie 平衡安全與體驗（預設值） └─ None 允許所有跨站請求 ⚠️ 必須同時設定 Secure Domain Cookie 的有效網域 設定錯誤可能導致 Cookie 無法傳送 Path Cookie 的有效路徑 預設為 /，限制可減少暴露面 Max-Age Cookie 存活時間（秒） Session Cookie 預設關閉瀏覽器即失效 Expires Cookie 過期時間戳 與 Max-Age 擇一使用 Strapi Session Cookie 預設值 Strapi 的 Session Cookie 預設設定如下：\n{ httpOnly: true, // 防止 XSS 竊取 secure: true, // 只在 HTTPS 傳輸（這就是問題點！） sameSite: \u0026#39;lax\u0026#39;, // 防止 CSRF 攻擊 signed: true, // 使用 APP_KEYS 簽章 maxAge: 86400000 // 1 天（毫秒） } 解決方案一：啟用 Strapi Proxy Trust Mode（推薦） 這是最正確的解決方案，讓 Strapi 信任來自 Ingress 的 Forwarded Headers。\n步驟 1：修改 Strapi 設定檔 編輯 config/server.js（或 config/server.ts）：\nexport default ({ env }) =\u0026gt; ({ host: env(\u0026#39;HOST\u0026#39;, \u0026#39;0.0.0.0\u0026#39;), port: env.int(\u0026#39;PORT\u0026#39;, 1337), // ✅ 關鍵設定：啟用 Koa Proxy Trust proxy: { enabled: true, // 啟用 Proxy 模式 koa: true, // 信任 X-Forwarded-* headers }, // 設定公開 URL（會影響生成的 URL） url: env(\u0026#39;PUBLIC_URL\u0026#39;, \u0026#39;https://cms.example.com\u0026#39;), app: { keys: env.array(\u0026#39;APP_KEYS\u0026#39;), }, // Webhook 設定（如果有使用） webhooks: { populateRelations: env.bool(\u0026#39;WEBHOOKS_POPULATE_RELATIONS\u0026#39;, false), }, }); 步驟 2：設定環境變數 在 Kubernetes ConfigMap 或 Secret 中設定：\napiVersion: v1 kind: ConfigMap metadata: name: strapi-config namespace: production data: HOST: \u0026#34;0.0.0.0\u0026#34; PORT: \u0026#34;1337\u0026#34; PUBLIC_URL: \u0026#34;https://cms.example.com\u0026#34; NODE_ENV: \u0026#34;production\u0026#34; 步驟 3：設定 Ingress Annotations 確保 Nginx Ingress Controller 傳送正確的 Headers：\napiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: strapi-ingress namespace: production annotations: # ✅ 強制使用 HTTPS nginx.ingress.kubernetes.io/force-ssl-redirect: \u0026#34;true\u0026#34; # ✅ 啟用 Forwarded Headers nginx.ingress.kubernetes.io/use-forwarded-headers: \u0026#34;true\u0026#34; # ✅ 設定真實 IP（用於日誌和速率限制） nginx.ingress.kubernetes.io/enable-real-ip: \u0026#34;true\u0026#34; # 如果使用 cert-manager 自動管理 SSL cert-manager.io/cluster-issuer: \u0026#34;letsencrypt-prod\u0026#34; # Proxy 緩衝設定（Strapi 上傳大檔案時需要） nginx.ingress.kubernetes.io/proxy-body-size: \u0026#34;100m\u0026#34; spec: ingressClassName: nginx tls: - hosts: - cms.example.com secretName: strapi-tls-cert rules: - host: cms.example.com http: paths: - path: / pathType: Prefix backend: service: name: strapi-svc port: number: 1337 步驟 4：驗證設定 部署後，可以透過以下方式驗證：\n# 1. 查看 Strapi Pod 日誌 kubectl logs -n production deployment/strapi --tail=100 # 2. 進入 Pod 檢查環境變數 kubectl exec -n production deployment/strapi -it -- env | grep PUBLIC_URL # 3. 測試 OAuth 流程 # 瀏覽器開啟 https://cms.example.com/admin # 點擊「Login with Google」 # 檢查瀏覽器開發者工具 Network 頁籤： # - 確認 callback 回應為 302 重導向（不是 500） # - 確認 Set-Cookie header 包含 Secure 屬性 # 4. 檢查 Ingress 轉發的 Headers（從 Pod 內部） kubectl exec -n production deployment/strapi -it -- sh # 在 Pod 內執行： apk add curl curl -H \u0026#34;Host: cms.example.com\u0026#34; http://localhost:1337/admin # 檢查回應，確認沒有錯誤 解決方案二：調整 Session Cookie 設定（僅測試環境） 如果只是在本機或測試環境遇到問題，可以暫時關閉 Secure Cookie 要求。\n方法 A：修改 Middleware 設定 建立或編輯 config/middlewares.js（或 .ts）：\nexport default [ \u0026#39;strapi::logger\u0026#39;, \u0026#39;strapi::errors\u0026#39;, \u0026#39;strapi::security\u0026#39;, \u0026#39;strapi::cors\u0026#39;, \u0026#39;strapi::poweredBy\u0026#39;, \u0026#39;strapi::query\u0026#39;, \u0026#39;strapi::body\u0026#39;, \u0026#39;strapi::session\u0026#39;, // 先保留預設 \u0026#39;strapi::favicon\u0026#39;, \u0026#39;strapi::public\u0026#39;, ]; 修改為自訂設定：\nexport default [ \u0026#39;strapi::logger\u0026#39;, \u0026#39;strapi::errors\u0026#39;, \u0026#39;strapi::security\u0026#39;, \u0026#39;strapi::cors\u0026#39;, \u0026#39;strapi::poweredBy\u0026#39;, \u0026#39;strapi::query\u0026#39;, \u0026#39;strapi::body\u0026#39;, // ⚠️ 自訂 Session 設定（僅開發環境！） { name: \u0026#39;strapi::session\u0026#39;, config: { cookie: { secure: false, // 關閉 Secure 要求 httpOnly: true, // 仍然防止 XSS sameSite: \u0026#39;lax\u0026#39;, // 仍然防止 CSRF maxAge: 86400000, // 1 天 }, }, }, \u0026#39;strapi::favicon\u0026#39;, \u0026#39;strapi::public\u0026#39;, ]; 方法 B：根據環境動態調整 更好的做法是根據 NODE_ENV 自動切換：\nexport default ({ env }) =\u0026gt; { const isProduction = env(\u0026#39;NODE_ENV\u0026#39;) === \u0026#39;production\u0026#39;; return [ \u0026#39;strapi::logger\u0026#39;, \u0026#39;strapi::errors\u0026#39;, \u0026#39;strapi::security\u0026#39;, \u0026#39;strapi::cors\u0026#39;, \u0026#39;strapi::poweredBy\u0026#39;, \u0026#39;strapi::query\u0026#39;, \u0026#39;strapi::body\u0026#39;, { name: \u0026#39;strapi::session\u0026#39;, config: { cookie: { secure: isProduction, // ✅ 生產環境啟用，開發環境關閉 httpOnly: true, sameSite: \u0026#39;lax\u0026#39;, maxAge: 86400000, }, }, }, \u0026#39;strapi::favicon\u0026#39;, \u0026#39;strapi::public\u0026#39;, ]; }; ⚠️ 警告：這個方法只適合本機開發或內部測試環境，絕對不要在生產環境關閉 Secure Cookie！\n解決方案三：Ingress 層級重寫（不推薦） 理論上可以在 Ingress 層級設定 Cookie，但這會破壞應用層的控制權，強烈不推薦。\n# ❌ 不推薦的做法 metadata: annotations: nginx.ingress.kubernetes.io/configuration-snippet: | proxy_cookie_path / \u0026#34;/; Secure; HttpOnly; SameSite=Lax\u0026#34;; 問題：\nStrapi 無法控制 Cookie 的生命週期 可能與應用層設定衝突 難以偵錯和維護 Google Cloud Console 設定 除了 Kubernetes 和 Strapi 設定，還需要在 Google Cloud Console 正確設定 OAuth 2.0 憑證。\n步驟 1：建立 OAuth 2.0 憑證 前往 Google Cloud Console 選擇你的專案（或建立新專案） 左側選單：APIs \u0026amp; Services \u0026gt; Credentials 點擊 Create Credentials \u0026gt; OAuth client ID Application type 選擇：Web application 設定名稱，例如：Strapi CMS - Production 步驟 2：設定 Authorized Redirect URIs 這是最容易出錯的部分！ Redirect URI 必須與實際 callback URL 完全一致。\n正確範例： ✅ https://cms.example.com/api/connect/google/callback ✅ https://staging-cms.example.com/api/connect/google/callback ✅ http://localhost:1337/api/connect/google/callback (本機開發) 錯誤範例： ❌ https://cms.example.com/api/connect/google/callback/ (多了斜線) ❌ https://cms.example.com/connect/google/callback (少了 /api) ❌ http://cms.example.com/api/connect/google/callback (http vs https) ❌ https://www.cms.example.com/api/connect/google/callback (多了 www) 常見錯誤訊息：\nError: redirect_uri_mismatch 這表示 callback URL 與 Google Console 設定不符。\n步驟 3：取得 Client ID 和 Client Secret 建立完成後，Google 會顯示：\nClient ID: 123456789-abc123def456.apps.googleusercontent.com Client Secret: GOCSPX-aBcDeFgHiJkLmNoPqRsTuVwXyZ 將這兩個值保存到 Kubernetes Secret：\nkubectl create secret generic strapi-google-oauth \\ --from-literal=GOOGLE_CLIENT_ID=\u0026#39;123456789-abc123def456.apps.googleusercontent.com\u0026#39; \\ --from-literal=GOOGLE_CLIENT_SECRET=\u0026#39;GOCSPX-aBcDeFgHiJkLmNoPqRsTuVwXyZ\u0026#39; \\ -n production 步驟 4：在 Strapi 啟用 Google Provider 登入 Strapi Admin：https://cms.example.com/admin\nSettings \u0026gt; Users \u0026amp; Permissions Plugin \u0026gt; Providers\n點擊 Google\n設定：\nEnable: ✅ 開啟 Client ID: ${GOOGLE_CLIENT_ID} （會從環境變數讀取） Client Secret: ${GOOGLE_CLIENT_SECRET} The redirect URL to your front-end app: https://cms.example.com/admin（登入成功後要跳轉的頁面） 點擊 Save\n完整部署架構 現在讓我們看看完整的生產環境部署架構：\n完整的 Kubernetes Manifests 範例 1. ConfigMap apiVersion: v1 kind: ConfigMap metadata: name: strapi-config namespace: production data: # Server 設定 HOST: \u0026#34;0.0.0.0\u0026#34; PORT: \u0026#34;1337\u0026#34; PUBLIC_URL: \u0026#34;https://cms.example.com\u0026#34; # 環境 NODE_ENV: \u0026#34;production\u0026#34; # 資料庫連線（非敏感部分） DATABASE_CLIENT: \u0026#34;postgres\u0026#34; DATABASE_HOST: \u0026#34;postgres-svc\u0026#34; DATABASE_PORT: \u0026#34;5432\u0026#34; DATABASE_NAME: \u0026#34;strapi\u0026#34; 2. Secrets apiVersion: v1 kind: Secret metadata: name: strapi-secrets namespace: production type: Opaque stringData: # 資料庫密碼 DATABASE_PASSWORD: \u0026#34;your-secure-password\u0026#34; # Strapi App Keys（用於 Cookie 簽章） APP_KEYS: \u0026#34;key1,key2,key3,key4\u0026#34; # JWT Secret JWT_SECRET: \u0026#34;your-jwt-secret\u0026#34; API_TOKEN_SALT: \u0026#34;your-api-token-salt\u0026#34; ADMIN_JWT_SECRET: \u0026#34;your-admin-jwt-secret\u0026#34; TRANSFER_TOKEN_SALT: \u0026#34;your-transfer-token-salt\u0026#34; --- apiVersion: v1 kind: Secret metadata: name: strapi-google-oauth namespace: production type: Opaque stringData: GOOGLE_CLIENT_ID: \u0026#34;123456789-abc123def456.apps.googleusercontent.com\u0026#34; GOOGLE_CLIENT_SECRET: \u0026#34;GOCSPX-aBcDeFgHiJkLmNoPqRsTuVwXyZ\u0026#34; 3. Deployment apiVersion: apps/v1 kind: Deployment metadata: name: strapi namespace: production spec: replicas: 3 selector: matchLabels: app: strapi template: metadata: labels: app: strapi spec: containers: - name: strapi image: your-registry/strapi:latest ports: - containerPort: 1337 name: http # 環境變數來自 ConfigMap envFrom: - configMapRef: name: strapi-config - secretRef: name: strapi-secrets - secretRef: name: strapi-google-oauth # 健康檢查 livenessProbe: httpGet: path: /_health port: 1337 initialDelaySeconds: 60 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /_health port: 1337 initialDelaySeconds: 30 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 # 資源限制 resources: requests: memory: \u0026#34;512Mi\u0026#34; cpu: \u0026#34;250m\u0026#34; limits: memory: \u0026#34;1Gi\u0026#34; cpu: \u0026#34;500m\u0026#34; # 掛載上傳檔案的儲存空間 volumeMounts: - name: uploads mountPath: /opt/app/public/uploads volumes: - name: uploads persistentVolumeClaim: claimName: strapi-uploads-pvc 4. Service apiVersion: v1 kind: Service metadata: name: strapi-svc namespace: production spec: selector: app: strapi ports: - port: 1337 targetPort: 1337 name: http type: ClusterIP 5. Ingress apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: strapi-ingress namespace: production annotations: # SSL 設定 cert-manager.io/cluster-issuer: \u0026#34;letsencrypt-prod\u0026#34; nginx.ingress.kubernetes.io/force-ssl-redirect: \u0026#34;true\u0026#34; # Proxy Headers nginx.ingress.kubernetes.io/use-forwarded-headers: \u0026#34;true\u0026#34; nginx.ingress.kubernetes.io/enable-real-ip: \u0026#34;true\u0026#34; # 檔案上傳大小限制 nginx.ingress.kubernetes.io/proxy-body-size: \u0026#34;100m\u0026#34; # Timeout 設定（Strapi 建置可能較久） nginx.ingress.kubernetes.io/proxy-read-timeout: \u0026#34;600\u0026#34; nginx.ingress.kubernetes.io/proxy-send-timeout: \u0026#34;600\u0026#34; spec: ingressClassName: nginx tls: - hosts: - cms.example.com secretName: strapi-tls-cert rules: - host: cms.example.com http: paths: - path: / pathType: Prefix backend: service: name: strapi-svc port: number: 1337 問題排查流程 當 Google OAuth 登入失敗時，可以按照這個流程系統化地排查問題：\n常見錯誤與解決方法 錯誤 1：Cannot send secure cookie over unencrypted connection 原因：Strapi 不信任 X-Forwarded-Proto header\n解決方法：\n// config/server.js export default ({ env }) =\u0026gt; ({ proxy: { koa: true }, // ✅ 啟用這個 url: env(\u0026#39;PUBLIC_URL\u0026#39;, \u0026#39;https://cms.example.com\u0026#39;), }); 錯誤 2：redirect_uri_mismatch 完整錯誤訊息：\nError: redirect_uri_mismatch The redirect URI in the request, https://cms.example.com/api/connect/google/callback, does not match the ones authorized for the OAuth client. 原因：Google Console 的 Authorized Redirect URIs 與實際 callback URL 不符\n解決方法：\n前往 Google Cloud Console \u0026gt; APIs \u0026amp; Services \u0026gt; Credentials 編輯 OAuth 2.0 Client ID 確保 Authorized Redirect URIs 包含：https://cms.example.com/api/connect/google/callback 注意：必須完全一致，包括 https://、網域、路徑 錯誤 3：Missing required parameter: client_id 原因：環境變數未正確設定或 Strapi Provider 設定錯誤\n解決方法：\n# 檢查環境變數 kubectl exec -n production deployment/strapi -- env | grep GOOGLE # 應該看到： # GOOGLE_CLIENT_ID=123456789-abc123def456.apps.googleusercontent.com # GOOGLE_CLIENT_SECRET=GOCSPX-... 如果變數存在但仍報錯，檢查 Strapi Admin 的 Provider 設定：\nSettings \u0026gt; Users \u0026amp; Permissions \u0026gt; Providers \u0026gt; Google 確認 Client ID 欄位是 ${GOOGLE_CLIENT_ID} 而非空白 錯誤 4：Could not retrieve provider profile from the provider 原因：無法從 Google 取得使用者資料，可能是 Access Token 無效\n常見情況：\nClient Secret 設定錯誤 Google API 權限問題 Token 過期 解決方法：\n# 1. 確認 Client Secret 正確 kubectl get secret strapi-google-oauth -n production -o yaml # 2. 檢查 Google Console 是否啟用必要的 API # - People API # - Google+ API (如果使用舊版) # 3. 查看詳細的 Strapi 日誌 kubectl logs -n production deployment/strapi --tail=200 | grep -A 10 \u0026#34;provider profile\u0026#34; 錯誤 5：Ingress 回傳 502 Bad Gateway 原因：Strapi Pod 尚未就緒或健康檢查失敗\n解決方法：\n# 檢查 Pod 狀態 kubectl get pods -n production -l app=strapi # 檢查 Pod 詳細資訊 kubectl describe pod -n production \u0026lt;pod-name\u0026gt; # 查看日誌 kubectl logs -n production \u0026lt;pod-name\u0026gt; --tail=100 # 常見原因： # 1. 資料庫連線失敗 -\u0026gt; 檢查 DATABASE_PASSWORD # 2. APP_KEYS 格式錯誤 -\u0026gt; 確保是逗號分隔的字串 # 3. 健康檢查路徑錯誤 -\u0026gt; Strapi 預設是 /_health 測試與驗證 1. 本機測試（Docker Compose） 在部署到 Kubernetes 之前，可以先用 Docker Compose 測試：\n# docker-compose.yml version: \u0026#39;3.8\u0026#39; services: strapi: image: strapi/strapi:latest environment: - HOST=0.0.0.0 - PORT=1337 - PUBLIC_URL=https://localhost:1337 - NODE_ENV=development - DATABASE_CLIENT=postgres - DATABASE_HOST=postgres - DATABASE_PORT=5432 - DATABASE_NAME=strapi - DATABASE_USERNAME=strapi - DATABASE_PASSWORD=strapi - GOOGLE_CLIENT_ID=your-client-id - GOOGLE_CLIENT_SECRET=your-client-secret volumes: - ./app:/srv/app ports: - \u0026#34;1337:1337\u0026#34; depends_on: - postgres postgres: image: postgres:14-alpine environment: - POSTGRES_DB=strapi - POSTGRES_USER=strapi - POSTGRES_PASSWORD=strapi volumes: - db-data:/var/lib/postgresql/data volumes: db-data: 注意：本機測試時需要在 Google Console 加入 http://localhost:1337/api/connect/google/callback\n2. 驗證 Proxy Headers 建立一個測試端點來檢查 Strapi 收到的 Headers：\n// src/api/test/routes/test.js module.exports = { routes: [ { method: \u0026#39;GET\u0026#39;, path: \u0026#39;/test/headers\u0026#39;, handler: \u0026#39;test.headers\u0026#39;, config: { auth: false, }, }, ], }; // src/api/test/controllers/test.js module.exports = { async headers(ctx) { return { protocol: ctx.protocol, host: ctx.host, ip: ctx.ip, headers: { \u0026#39;x-forwarded-proto\u0026#39;: ctx.get(\u0026#39;X-Forwarded-Proto\u0026#39;), \u0026#39;x-forwarded-host\u0026#39;: ctx.get(\u0026#39;X-Forwarded-Host\u0026#39;), \u0026#39;x-forwarded-for\u0026#39;: ctx.get(\u0026#39;X-Forwarded-For\u0026#39;), \u0026#39;x-real-ip\u0026#39;: ctx.get(\u0026#39;X-Real-IP\u0026#39;), }, }; }, }; 部署後訪問 https://cms.example.com/api/test/headers，確認：\nprotocol: \u0026quot;https\u0026quot; headers.x-forwarded-proto: \u0026quot;https\u0026quot; 3. 完整的 OAuth 流程測試 建立測試檢查清單：\nGoogle Console 設定\nRedirect URI 正確無誤 Client ID 和 Secret 已複製 OAuth consent screen 已設定（測試/生產模式） Kubernetes 設定\nSecret 包含 GOOGLE_CLIENT_ID 和 GOOGLE_CLIENT_SECRET ConfigMap 包含正確的 PUBLIC_URL Deployment 正確掛載 Secret 和 ConfigMap Ingress annotations 包含 use-forwarded-headers Strapi 設定\nconfig/server.js 啟用 proxy.koa Admin \u0026gt; Settings \u0026gt; Providers \u0026gt; Google 已啟用 Provider 設定使用 ${GOOGLE_CLIENT_ID} 變數 端到端測試\n訪問 https://cms.example.com/admin 點擊「Login with Google」 正確跳轉到 Google 授權頁 授權後成功跳轉回 Strapi 成功登入，顯示使用者資訊 重新整理頁面，Session 仍然有效 安全最佳實踐 1. 使用 Kubernetes Secrets 管理敏感資訊 # ✅ 正確：使用 Secret kubectl create secret generic strapi-google-oauth \\ --from-literal=GOOGLE_CLIENT_ID=\u0026#39;...\u0026#39; \\ --from-literal=GOOGLE_CLIENT_SECRET=\u0026#39;...\u0026#39; # ❌ 錯誤：寫在 ConfigMap 中（明文） 2. 限制 OAuth 的 Scope 在 Strapi Provider 設定中，可以限制要求的權限：\n// config/plugins.js export default { \u0026#39;users-permissions\u0026#39;: { config: { providers: { google: { enabled: true, scope: [\u0026#39;email\u0026#39;, \u0026#39;profile\u0026#39;], // 只要求基本資料 }, }, }, }, }; 3. 實施 Rate Limiting 防止暴力破解或濫用：\n# Ingress annotations nginx.ingress.kubernetes.io/rate-limit: \u0026#34;10\u0026#34; nginx.ingress.kubernetes.io/rate-limit-burst: \u0026#34;20\u0026#34; 4. 啟用 HSTS（HTTP Strict Transport Security） 強制瀏覽器只使用 HTTPS：\n# Ingress annotations nginx.ingress.kubernetes.io/hsts: \u0026#34;true\u0026#34; nginx.ingress.kubernetes.io/hsts-max-age: \u0026#34;31536000\u0026#34; nginx.ingress.kubernetes.io/hsts-include-subdomains: \u0026#34;true\u0026#34; 5. 設定 Content Security Policy 在 Strapi 的 middleware 中設定 CSP：\n// config/middlewares.js export default [ // ... { name: \u0026#39;strapi::security\u0026#39;, config: { contentSecurityPolicy: { directives: { \u0026#39;default-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;], \u0026#39;script-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#34;\u0026#39;unsafe-inline\u0026#39;\u0026#34;, \u0026#39;https://accounts.google.com\u0026#39;], \u0026#39;frame-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;https://accounts.google.com\u0026#39;], \u0026#39;connect-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;https://accounts.google.com\u0026#39;], }, }, }, }, // ... ]; 6. 定期輪換 Secrets # 建立新的 Client Secret（在 Google Console） # 更新 Kubernetes Secret kubectl create secret generic strapi-google-oauth \\ --from-literal=GOOGLE_CLIENT_SECRET=\u0026#39;new-secret\u0026#39; \\ --dry-run=client -o yaml | kubectl apply -f - # 重啟 Pod 套用新設定 kubectl rollout restart deployment strapi -n production 多環境管理策略 在實際專案中，通常會有多個環境（Development、Staging、Production），每個環境都需要獨立的 OAuth 設定。\n環境隔離架構 Kustomize 多環境設定 使用 Kustomize 管理不同環境的差異：\n# 目錄結構 k8s/ ├── base/ │ ├── kustomization.yaml │ ├── deployment.yaml │ ├── service.yaml │ └── ingress.yaml ├── overlays/ │ ├── staging/ │ │ ├── kustomization.yaml │ │ ├── configmap.yaml │ │ └── secrets.yaml (加密) │ └── production/ │ ├── kustomization.yaml │ ├── configmap.yaml │ └── secrets.yaml (加密) base/kustomization.yaml:\napiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - deployment.yaml - service.yaml - ingress.yaml overlays/production/kustomization.yaml:\napiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: production bases: - ../../base resources: - configmap.yaml - secrets.yaml patchesStrategicMerge: - patch-ingress.yaml images: - name: strapi newName: your-registry/strapi newTag: v1.2.3 部署命令:\n# Staging kubectl apply -k k8s/overlays/staging # Production kubectl apply -k k8s/overlays/production 監控與告警 1. 設定 Prometheus Metrics Strapi 可以透過 middleware 暴露 metrics：\n// config/middlewares.js const promClient = require(\u0026#39;prom-client\u0026#39;); // 建立 metrics registry const register = new promClient.Register(); // 定義自訂 metrics const oauthSuccessCounter = new promClient.Counter({ name: \u0026#39;strapi_oauth_success_total\u0026#39;, help: \u0026#39;Total number of successful OAuth logins\u0026#39;, labelNames: [\u0026#39;provider\u0026#39;], registers: [register], }); const oauthFailureCounter = new promClient.Counter({ name: \u0026#39;strapi_oauth_failure_total\u0026#39;, help: \u0026#39;Total number of failed OAuth logins\u0026#39;, labelNames: [\u0026#39;provider\u0026#39;, \u0026#39;error_type\u0026#39;], registers: [register], }); module.exports = [ // ... 其他 middlewares { name: \u0026#39;strapi::metrics\u0026#39;, config: { path: \u0026#39;/metrics\u0026#39;, handler: async (ctx) =\u0026gt; { ctx.body = await register.metrics(); ctx.type = \u0026#39;text/plain\u0026#39;; }, }, }, ]; 2. Grafana Dashboard 建立 Dashboard 監控 OAuth 登入狀況：\nOAuth 成功/失敗次數（每小時） 回應時間分布 錯誤類型分布 各 Provider 使用率 3. 告警規則 在 Prometheus 設定告警規則：\ngroups: - name: strapi_oauth interval: 1m rules: - alert: HighOAuthFailureRate expr: | rate(strapi_oauth_failure_total[5m]) \u0026gt; 0.1 for: 5m labels: severity: warning annotations: summary: \u0026#34;High OAuth failure rate detected\u0026#34; description: \u0026#34;OAuth failure rate is {{ $value }} per second\u0026#34; - alert: OAuthCompletelyBroken expr: | rate(strapi_oauth_success_total[10m]) == 0 AND rate(strapi_oauth_failure_total[10m]) \u0026gt; 0 for: 10m labels: severity: critical annotations: summary: \u0026#34;OAuth completely broken - no successful logins\u0026#34; 效能優化 1. Session Storage 預設 Strapi 使用記憶體儲存 Session，多 Pod 環境會有問題。建議改用 Redis：\n// config/middlewares.js const Redis = require(\u0026#39;ioredis\u0026#39;); const redisStore = require(\u0026#39;koa-redis\u0026#39;); const redisClient = new Redis({ host: process.env.REDIS_HOST || \u0026#39;redis-svc\u0026#39;, port: process.env.REDIS_PORT || 6379, password: process.env.REDIS_PASSWORD, db: 0, }); module.exports = [ // ... { name: \u0026#39;strapi::session\u0026#39;, config: { store: redisStore({ client: redisClient }), cookie: { secure: true, httpOnly: true, sameSite: \u0026#39;lax\u0026#39;, maxAge: 86400000, }, }, }, ]; 2. Token Caching 避免每次請求都驗證 JWT，可以使用 Redis 快取驗證結果。\n3. Database Connection Pooling // config/database.js export default ({ env }) =\u0026gt; ({ connection: { client: \u0026#39;postgres\u0026#39;, connection: { host: env(\u0026#39;DATABASE_HOST\u0026#39;), port: env.int(\u0026#39;DATABASE_PORT\u0026#39;), database: env(\u0026#39;DATABASE_NAME\u0026#39;), user: env(\u0026#39;DATABASE_USERNAME\u0026#39;), password: env(\u0026#39;DATABASE_PASSWORD\u0026#39;), ssl: env.bool(\u0026#39;DATABASE_SSL\u0026#39;, false), }, pool: { min: 2, max: 10, acquireTimeoutMillis: 30000, idleTimeoutMillis: 30000, }, }, }); 常見問題 Q\u0026amp;A Q1: 為什麼本機可以登入，部署到 Kubernetes 就失敗？ A: 這是因為本機環境通常直接連到 Strapi（http://localhost:1337），而 Kubernetes 有 Ingress 層的 TLS 終止。需要啟用 Strapi 的 Proxy Trust 模式。\nQ2: 可以同時支援多個 OAuth Provider 嗎？ A: 可以！Strapi 支援多個 Provider（Google、Facebook、GitHub、Twitter 等），每個 Provider 都有獨立的設定。使用者可以選擇任一方式登入。\nQ3: OAuth 登入後如何自動建立使用者？ A: Strapi 會自動處理。首次 OAuth 登入時，會在 users collection 建立新記錄，email 來自 OAuth provider。可以在 bootstrap.js 中自訂邏輯：\n// src/index.js module.exports = { async bootstrap({ strapi }) { // 監聽 OAuth 使用者建立事件 strapi.db.lifecycles.subscribe({ models: [\u0026#39;plugin::users-permissions.user\u0026#39;], async afterCreate(event) { const { result } = event; if (result.provider === \u0026#39;google\u0026#39;) { // 自訂邏輯：發送歡迎信、指派預設角色等 console.log(\u0026#39;New Google user:\u0026#39;, result.email); } }, }); }, }; Q4: 如何限制只有特定 domain 的 Google 帳號可以登入？ A: 可以在 OAuth callback 處理中加入驗證：\n// src/extensions/users-permissions/strapi-server.js module.exports = (plugin) =\u0026gt; { const originalConnect = plugin.controllers.auth.callback; plugin.controllers.auth.callback = async (ctx) =\u0026gt; { // 執行原本的 OAuth 流程 await originalConnect(ctx); // 檢查使用者 email domain const user = ctx.state.user; if (user \u0026amp;\u0026amp; user.email \u0026amp;\u0026amp; !user.email.endsWith(\u0026#39;@yourcompany.com\u0026#39;)) { ctx.unauthorized(\u0026#39;Only company emails are allowed\u0026#39;); } }; return plugin; }; Q5: Session Cookie 一直過期怎麼辦？ A: 檢查以下幾點：\nCookie 的 maxAge 設定（預設 24 小時） 如果有多個 Pod，確保使用共享的 Session Storage（如 Redis） 檢查瀏覽器是否封鎖 Third-party Cookies 確認時區設定正確（Server 與 Client 時間差可能導致問題） Q6: 如何在前端實作「Login with Google」按鈕？ A: Strapi 提供標準的 REST API：\n// React 範例 function GoogleLoginButton() { const handleLogin = () =\u0026gt; { // 重導向到 Strapi 的 Google OAuth 端點 window.location.href = \u0026#39;https://cms.example.com/api/connect/google\u0026#39;; }; return ( \u0026lt;button onClick={handleLogin}\u0026gt; Login with Google \u0026lt;/button\u0026gt; ); } Strapi 會處理整個 OAuth 流程，最後重導向回 redirect 參數指定的頁面（或 Provider 設定的預設頁面），並在 URL 中帶著 access_token：\nhttps://your-frontend.com/auth/callback?access_token=eyJhbGc... 前端再用這個 token 呼叫 Strapi API。\nQ7: 生產環境一定要用 HTTPS 嗎？ A: 是的，絕對必要！ 原因：\nGoogle OAuth 要求 Redirect URI 必須是 HTTPS（本機 localhost 例外） Secure Cookie 只能在 HTTPS 傳輸 防止中間人攻擊竊取使用者資料 現代瀏覽器對 HTTP 網站有諸多限制（Service Worker、Geolocation 等） 建議使用 cert-manager + Let\u0026rsquo;s Encrypt 自動管理 SSL 憑證。\n總結 整合 Google OAuth 到 Kubernetes 上的 Strapi，表面上是個簡單的功能，實際上牽涉到：\nOAuth 2.0 協定：理解授權碼流程、Token 交換機制 Kubernetes 網路架構：Ingress、Service、Pod 的流量路徑 TLS 終止與 Proxy Trust：理解 Reverse Proxy 的運作原理 Cookie 安全機制：Secure、HttpOnly、SameSite 屬性 環境變數管理：Kubernetes ConfigMap、Secret 的最佳實踐 多環境部署：Development、Staging、Production 的隔離策略 這次踩坑的核心問題是：Strapi 預設不信任來自 Ingress 的 X-Forwarded-Proto header，導致它認為連線是 HTTP，拒絕設定 Secure Cookie。\n解決方案很簡單：在 config/server.js 加入 proxy: { koa: true }，並確保 Ingress 正確傳送 Forwarded Headers。\n但更重要的是理解背後的原理：\n為什麼需要 Proxy Trust？（安全性與正確性的平衡） Forwarded Headers 的意義？（保留原始請求資訊） Cookie 安全屬性的作用？（防止各種攻擊） Kubernetes 網路層的運作？（TLS 終止、負載平衡） 希望這篇文章能幫助你在 Kubernetes 上順利部署 Strapi + Google OAuth，並在遇到問題時快速定位原因！\n參考資源 Strapi 官方文件 - Providers Google OAuth 2.0 文件 Koa.js 官方文件 - Proxy Support Kubernetes Ingress Nginx - Annotations MDN - Set-Cookie RFC 7239 - Forwarded HTTP Extension ","permalink":"https://peter-notes.com/posts/strapi-google-auth-k8s-secure-cookie/","summary":"\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e在現代 Web 應用開發中，提供第三方登入（Social Login）已經成為標準配備。相比傳統的帳號密碼註冊流程，使用 Google、Facebook、GitHub 等服務登入不僅能降低使用者註冊門檻，還能提升安全性（由大廠處理密碼儲存與驗證）。\u003c/p\u003e\n\u003cp\u003e當我們決定為 Strapi CMS 後台加入 Google OAuth 登入時，原本以為只是個簡單的設定任務：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e在 Google Cloud Console 建立 OAuth 2.0 憑證\u003c/li\u003e\n\u003cli\u003e在 Strapi 填入 Client ID 和 Client Secret\u003c/li\u003e\n\u003cli\u003e點擊「Login with Google」按鈕，完成！\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e但現實總是更複雜。當應用部署到 Kubernetes 叢集後，我們遇到了一個令人困惑的錯誤訊息：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eError: Cannot send secure cookie over unencrypted connection\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e這個錯誤訊息背後，牽涉到 HTTP/HTTPS 協定、Proxy Trust 機制、Kubernetes Ingress 架構、以及瀏覽器 Cookie 安全策略等多層知識。這篇文章將完整記錄我如何一步步拆解問題、理解根本原因、並最終在生產環境中實現安全可靠的 Google 登入功能。\u003c/p\u003e\n\u003ch2 id=\"oauth-20-授權碼流程基礎\"\u003eOAuth 2.0 授權碼流程基礎\u003c/h2\u003e\n\u003cp\u003e在深入問題之前，我們先理解 Google OAuth 登入的完整流程。OAuth 2.0 提供了多種授權模式（Grant Types），而 Web 應用最常使用的是「授權碼模式（Authorization Code Flow）」。\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/2025-05-16-整合-google-登入到-strapi在-kubernetes-上攻克secure-cookie-diagram-fe86ac1f.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e這個流程中有幾個關鍵點：\u003c/p\u003e","title":"整合 Google 登入至 Strapi：在 Kubernetes 上解決「Secure Cookie over Unencrypted Connection」的實戰紀錄"},{"content":"前言：一個簡單的環境變數引發的災難 在部署 Strapi CMS 到 Kubernetes 正式環境時，只是加了一行看似無害的環境變數設定：\nenv: - name: NODE_ENV value: production # 就是這一行！ 結果卻導致整個管理後台變成一片空白，連登入頁面都看不到。更詭異的是：\n✅ API 完全正常，GraphQL 和 REST 都能回應 ✅ Pod 狀態正常，沒有任何錯誤訊息 ✅ 日誌顯示 Strapi 成功啟動 ❌ 瀏覽器打開 /admin 卻是一片空白 這種「Schrodinger 的服務」（同時正常又不正常）讓人抓狂。經過一番排查，終於發現罪魁禍首是 CSP (Content Security Policy) 在作怪。\n本文將深入探討：\n為什麼正式環境會出現空白頁 CSP 的工作原理與安全機制 完整的問題排查步驟 如何正確配置 Strapi 的安全策略 生產環境的安全最佳實踐 問題背景：開發正常，正式環境空白 環境差異對比 問題現象詳細描述 Kubernetes Deployment 設定：\napiVersion: apps/v1 kind: Deployment metadata: name: strapi-prod namespace: default spec: replicas: 1 selector: matchLabels: app: strapi template: metadata: labels: app: strapi spec: containers: - name: strapi image: myregistry.com/strapi:v5.0.0 env: - name: NODE_ENV value: production # 問題的起點 - name: DATABASE_HOST valueFrom: secretKeyRef: name: strapi-db-secret key: host - name: ADMIN_JWT_SECRET valueFrom: secretKeyRef: name: strapi-admin-secret key: jwt-secret ports: - containerPort: 1337 部署後的症狀：\nPod 狀態看起來完美\n$ kubectl get pods -n default NAME READY STATUS RESTARTS AGE strapi-prod-66cb7494c5-abcde 1/1 Running 0 5m 日誌顯示正常啟動\n$ kubectl logs strapi-prod-66cb7494c5-abcde -n default [2025-05-07 10:15:23.456] INFO: Server started on port 1337 [2025-05-07 10:15:23.567] INFO: Database connection established [2025-05-07 10:15:23.678] INFO: Admin panel available at /admin API 測試完全正常\n# GraphQL 查詢正常 $ curl -X POST https://cms.example.com/graphql \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;query\u0026#34;: \u0026#34;{ articles { title } }\u0026#34;}\u0026#39; {\u0026#34;data\u0026#34;:{\u0026#34;articles\u0026#34;:[{\u0026#34;title\u0026#34;:\u0026#34;Test Article\u0026#34;}]}} ✅ # REST API 也正常 $ curl https://cms.example.com/api/articles {\u0026#34;data\u0026#34;:[{\u0026#34;id\u0026#34;:1,\u0026#34;attributes\u0026#34;:{\u0026#34;title\u0026#34;:\u0026#34;Test Article\u0026#34;}}]} ✅ 但瀏覽器打開後台是空白的\nhttps://cms.example.com/admin 顯示：一片空白（白屏） 問題排查：抽絲剝繭找出真相 排查流程圖 步驟 1：確認 Pod 和服務狀態 # 檢查 Pod 狀態 kubectl get pods -n default -l app=strapi # 檢查 Pod 事件 kubectl describe pod strapi-prod-66cb7494c5-abcde -n default # 查看 Strapi 日誌 kubectl logs strapi-prod-66cb7494c5-abcde -n default --tail=100 # 檢查 Service kubectl get svc -n default -l app=strapi 結果：✅ 全部正常，沒有任何錯誤訊息\n步驟 2：測試 API 功能 # 測試 REST API curl -i https://cms.example.com/api/articles # 測試 GraphQL curl -X POST https://cms.example.com/graphql \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;query\u0026#34;: \u0026#34;{ articles { id title } }\u0026#34;}\u0026#39; # 測試健康檢查端點 curl https://cms.example.com/_health 結果：✅ API 完全正常，資料庫連線正常\n步驟 3：瀏覽器 DevTools 檢查（關鍵！） 打開 Chrome DevTools（F12 或 Cmd+Option+I）\n1. Console 標籤顯示錯誤：\nRefused to load the script \u0026#39;https://cms.example.com/_content/admin/strapi-CNBo0SYx.js\u0026#39; because it violates the following Content Security Policy directive: \u0026#34;script-src \u0026#39;self\u0026#39; blob:\u0026#34;. Note that \u0026#39;script-src-elem\u0026#39; was not explicitly set, so \u0026#39;script-src\u0026#39; is used as a fallback. 2. Network 標籤顯示：\nstrapi-CNBo0SYx.js (blocked:csp) Failed to load resource runtime-CNBo0SYx.js (blocked:csp) Failed to load resource vendor-CNBo0SYx.js (blocked:csp) Failed to load resource 找到了！JavaScript 檔案被 CSP (Content Security Policy) 阻擋！\nCSP (Content Security Policy) 深度解析 什麼是 CSP？ CSP 是一種瀏覽器安全機制，用於防止 XSS (跨站腳本攻擊) 和其他程式碼注入攻擊。它透過 HTTP Header 告訴瀏覽器哪些資源來源是可信的。\nCSP 工作原理：\nCSP 指令說明 常見的 CSP 指令：\n指令 說明 範例 default-src 預設來源（其他未指定的都套用此規則） 'self' script-src JavaScript 來源 'self' 'unsafe-inline' style-src CSS 樣式來源 'self' https://fonts.googleapis.com img-src 圖片來源 'self' data: https: connect-src AJAX/WebSocket 連線來源 'self' https://api.example.com font-src 字型來源 'self' https://fonts.gstatic.com frame-src iframe 來源 'none' 特殊關鍵字（必須加引號）：\n關鍵字 說明 安全性 'none' 不允許任何來源 最安全 'self' 只允許同源 安全 'unsafe-inline' 允許行內腳本/樣式 ⚠️ 較不安全 'unsafe-eval' 允許 eval() 等動態執行 ⚠️ 不安全 'strict-dynamic' 動態載入腳本（需配合 nonce） 較安全 Strapi 預設的 CSP 設定 開發模式（NODE_ENV=development）：\n// Strapi 內建預設 { directives: { \u0026#39;script-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#34;\u0026#39;unsafe-inline\u0026#39;\u0026#34;, \u0026#39;cdn.jsdelivr.net\u0026#39;], \u0026#39;img-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;data:\u0026#39;, \u0026#39;blob:\u0026#39;, \u0026#39;cdn.jsdelivr.net\u0026#39;], \u0026#39;connect-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;https:\u0026#39;], // ... 較寬鬆的設定 } } 正式模式（NODE_ENV=production）：\n// Strapi 自動啟用嚴格模式 { directives: { \u0026#39;script-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;blob:\u0026#39;], // ❌ 缺少 \u0026#39;unsafe-inline\u0026#39; \u0026#39;img-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;data:\u0026#39;, \u0026#39;blob:\u0026#39;], \u0026#39;connect-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;], // ... 更嚴格的設定 } } 問題所在：\nStrapi Admin Panel 會動態載入打包後的 JS 檔案（如 strapi-CNBo0SYx.js） 這些檔案需要 'unsafe-inline' 和可能的外部來源 正式環境的嚴格 CSP 阻擋了這些檔案的載入 解決方案：正確配置 CSP 設定 方案 1：修改 Strapi 中介層設定（推薦） 位置：config/middlewares.js\nmodule.exports = [ \u0026#39;strapi::logger\u0026#39;, \u0026#39;strapi::errors\u0026#39;, { name: \u0026#39;strapi::security\u0026#39;, config: { contentSecurityPolicy: { useDefaults: true, directives: { // 允許從同源和行內載入腳本 \u0026#39;script-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#34;\u0026#39;unsafe-inline\u0026#39;\u0026#34;, \u0026#39;cdn.jsdelivr.net\u0026#39;], // 如果使用外部 CDN 或自訂域名 \u0026#39;script-src-elem\u0026#39;: [ \u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;cdn.jsdelivr.net\u0026#39;, process.env.PUBLIC_URL, // 允許從設定的 CDN 載入 ], // 允許圖片來源 \u0026#39;img-src\u0026#39;: [ \u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;data:\u0026#39;, \u0026#39;blob:\u0026#39;, \u0026#39;cdn.jsdelivr.net\u0026#39;, \u0026#39;strapi.io\u0026#39;, process.env.CDN_URL, ], // 允許媒體來源 \u0026#39;media-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;data:\u0026#39;, \u0026#39;blob:\u0026#39;, process.env.CDN_URL], // 允許 API 連線 \u0026#39;connect-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;https:\u0026#39;], // 允許字型來源 \u0026#39;font-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;data:\u0026#39;], // 允許樣式來源 \u0026#39;style-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#34;\u0026#39;unsafe-inline\u0026#39;\u0026#34;, \u0026#39;cdn.jsdelivr.net\u0026#39;], // Frame ancestors（防止被嵌入 iframe） \u0026#39;frame-ancestors\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;], }, }, }, }, \u0026#39;strapi::cors\u0026#39;, \u0026#39;strapi::poweredBy\u0026#39;, \u0026#39;strapi::query\u0026#39;, \u0026#39;strapi::body\u0026#39;, \u0026#39;strapi::session\u0026#39;, \u0026#39;strapi::favicon\u0026#39;, \u0026#39;strapi::public\u0026#39;, ]; 關鍵說明：\n1. 'self' 必須加引號\n// ❌ 錯誤 \u0026#39;script-src\u0026#39;: [self] // ✅ 正確 \u0026#39;script-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;] 2. 'unsafe-inline' 的必要性\nStrapi Admin Panel 使用了一些行內腳本和樣式，如果不加這個會被阻擋。雖然名字有 \u0026ldquo;unsafe\u0026rdquo;，但對於管理後台來說是可接受的（因為只有管理員能存取）。\n3. process.env.PUBLIC_URL 動態設定\n如果你的靜態資源部署在 CDN：\n# Kubernetes Deployment env: - name: PUBLIC_URL value: \u0026#34;https://cdn.myshop.com\u0026#34; - name: CDN_URL value: \u0026#34;https://cdn.myshop.com\u0026#34; 4. useDefaults: true\n保留 Strapi 預設的基本規則，只覆蓋需要調整的部分。\n方案 2：環境變數控制（靈活） 建立 config/middlewares.js（支援環境變數）：\nconst isDevelopment = process.env.NODE_ENV === \u0026#39;development\u0026#39;; module.exports = [ \u0026#39;strapi::logger\u0026#39;, \u0026#39;strapi::errors\u0026#39;, { name: \u0026#39;strapi::security\u0026#39;, config: { contentSecurityPolicy: { useDefaults: true, directives: { \u0026#39;script-src\u0026#39;: [ \u0026#34;\u0026#39;self\u0026#39;\u0026#34;, ...(isDevelopment ? [\u0026#34;\u0026#39;unsafe-inline\u0026#39;\u0026#34;, \u0026#34;\u0026#39;unsafe-eval\u0026#39;\u0026#34;] : [\u0026#34;\u0026#39;unsafe-inline\u0026#39;\u0026#34;]), \u0026#39;cdn.jsdelivr.net\u0026#39;, process.env.CDN_URL, ].filter(Boolean), \u0026#39;connect-src\u0026#39;: [ \u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;https:\u0026#39;, ...(isDevelopment ? [\u0026#39;http:\u0026#39;, \u0026#39;ws:\u0026#39;] : []), // 開發模式允許 WebSocket ].filter(Boolean), \u0026#39;img-src\u0026#39;: [ \u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;data:\u0026#39;, \u0026#39;blob:\u0026#39;, process.env.CDN_URL, process.env.UPLOAD_CDN_URL, ].filter(Boolean), }, }, }, }, \u0026#39;strapi::cors\u0026#39;, \u0026#39;strapi::poweredBy\u0026#39;, \u0026#39;strapi::query\u0026#39;, \u0026#39;strapi::body\u0026#39;, \u0026#39;strapi::session\u0026#39;, \u0026#39;strapi::favicon\u0026#39;, \u0026#39;strapi::public\u0026#39;, ]; 方案 3：Nginx/Ingress 層級設定（不推薦） 也可以在 Nginx Ingress 設定 CSP Header，但這樣會覆蓋 Strapi 的設定：\napiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: strapi-ingress annotations: nginx.ingress.kubernetes.io/configuration-snippet: | add_header Content-Security-Policy \u0026#34;script-src \u0026#39;self\u0026#39; \u0026#39;unsafe-inline\u0026#39; cdn.jsdelivr.net;\u0026#34; always; spec: rules: - host: cms.example.com http: paths: - path: / pathType: Prefix backend: service: name: strapi-service port: number: 1337 不推薦的原因：\n難以維護（設定分散在多個地方） Nginx 設定會覆蓋應用層設定 不利於開發/測試環境的一致性 部署流程與驗證 完整部署流程 步驟 1：更新 Dockerfile FROM node:18-alpine WORKDIR /app # 複製 package files COPY package.json package-lock.json ./ RUN npm ci --only=production # 複製應用程式碼 COPY . . # 複製更新的 middlewares 設定 COPY config/middlewares.js ./config/ # 建置 Admin Panel ENV NODE_ENV=production RUN npm run build # 暴露 port EXPOSE 1337 # 啟動 CMD [\u0026#34;npm\u0026#34;, \u0026#34;start\u0026#34;] 步驟 2：建置並推送 # 建置映像檔 docker build -t myregistry.com/strapi:v5.0.1 . # 推送到 Registry docker push myregistry.com/strapi:v5.0.1 步驟 3：更新 Kubernetes Deployment apiVersion: apps/v1 kind: Deployment metadata: name: strapi-prod namespace: default spec: replicas: 1 selector: matchLabels: app: strapi template: metadata: labels: app: strapi version: v5.0.1 spec: containers: - name: strapi image: myregistry.com/strapi:v5.0.1 # 更新版本 env: - name: NODE_ENV value: production - name: PUBLIC_URL value: \u0026#34;https://cms.example.com\u0026#34; - name: CDN_URL value: \u0026#34;https://cdn.example.com\u0026#34; - name: DATABASE_HOST valueFrom: secretKeyRef: name: strapi-db-secret key: host ports: - containerPort: 1337 livenessProbe: httpGet: path: /_health port: 1337 initialDelaySeconds: 60 periodSeconds: 30 readinessProbe: httpGet: path: /_health port: 1337 initialDelaySeconds: 30 periodSeconds: 10 步驟 4：部署並驗證 # 套用設定 kubectl apply -f deployment.yaml # 監控部署狀態 kubectl rollout status deployment/strapi-prod -n default # 查看 Pod 狀態 kubectl get pods -n default -l app=strapi -w # 查看日誌 kubectl logs -f deployment/strapi-prod -n default 步驟 5：驗證 CSP Header # 檢查回應的 CSP Header curl -I https://cms.example.com/admin # 應該看到類似這樣的 Header HTTP/2 200 content-security-policy: script-src \u0026#39;self\u0026#39; \u0026#39;unsafe-inline\u0026#39; cdn.jsdelivr.net; img-src \u0026#39;self\u0026#39; data: blob:; connect-src \u0026#39;self\u0026#39; https:; 或使用瀏覽器 DevTools：\nNetwork → 選擇 admin 頁面 → Headers → Response Headers 查找 content-security-policy 步驟 6：功能測試檢查清單 Admin Panel 首頁正常顯示 能夠登入管理後台 Content Manager 正常載入 Media Library 圖片正常顯示 可以新增/編輯/刪除內容 GraphQL Playground 正常運作 API 端點正常回應 瀏覽器 Console 沒有 CSP 錯誤 安全性最佳實踐 1. 最小權限原則 只開放必要的來源，避免使用過於寬鬆的設定：\n// ❌ 不安全：允許任何 HTTPS 來源 \u0026#39;script-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;https:\u0026#39;] // ✅ 安全：明確指定可信來源 \u0026#39;script-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#34;\u0026#39;unsafe-inline\u0026#39;\u0026#34;, \u0026#39;cdn.jsdelivr.net\u0026#39;] 2. 避免 'unsafe-eval' 盡可能不要使用 'unsafe-eval'，它會允許 eval() 等動態程式碼執行：\n// ❌ 非常不安全 \u0026#39;script-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#34;\u0026#39;unsafe-inline\u0026#39;\u0026#34;, \u0026#34;\u0026#39;unsafe-eval\u0026#39;\u0026#34;] // ✅ 只使用必要的 unsafe-inline \u0026#39;script-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#34;\u0026#39;unsafe-inline\u0026#39;\u0026#34;] 3. 使用 Nonce 或 Hash（進階） 對於更高的安全性，可以使用 nonce 或 hash：\n// 使用 nonce（需要在每個請求動態生成） module.exports = { name: \u0026#39;strapi::security\u0026#39;, config: { contentSecurityPolicy: { useDefaults: true, directives: { \u0026#39;script-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, (ctx) =\u0026gt; `\u0026#39;nonce-${ctx.state.nonce}\u0026#39;`], }, }, }, }; 4. 分離 Admin 和 API 更安全的做法是將 Admin Panel 和 Public API 分開部署：\n# Admin Panel (內部網路) apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: strapi-admin-ingress spec: rules: - host: admin.internal.example.com http: paths: - path: /admin pathType: Prefix backend: service: name: strapi-service port: number: 1337 --- # Public API (對外開放) apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: strapi-api-ingress spec: rules: - host: api.example.com http: paths: - path: / pathType: Prefix backend: service: name: strapi-service port: number: 1337 5. 監控 CSP 違規 設定 CSP 報告端點來追蹤違規：\nmodule.exports = { name: \u0026#39;strapi::security\u0026#39;, config: { contentSecurityPolicy: { useDefaults: true, directives: { \u0026#39;script-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#34;\u0026#39;unsafe-inline\u0026#39;\u0026#34;], \u0026#39;report-uri\u0026#39;: [\u0026#39;/csp-violation-report\u0026#39;], // 違規報告 }, }, }, }; 建立接收違規報告的端點：\n// api/csp-report/routes/csp-report.js module.exports = { routes: [ { method: \u0026#39;POST\u0026#39;, path: \u0026#39;/csp-violation-report\u0026#39;, handler: \u0026#39;csp-report.receive\u0026#39;, config: { auth: false, // CSP 報告不需要認證 }, }, ], }; // api/csp-report/controllers/csp-report.js module.exports = { async receive(ctx) { const report = ctx.request.body; strapi.log.warn(\u0026#39;CSP Violation:\u0026#39;, report); // 可以儲存到資料庫或發送到監控系統 ctx.body = { received: true }; }, }; 常見問題與解決方案 Q1: 為什麼只有 Admin 空白，API 正常？ A: Admin Panel 是 SPA (Single Page Application)，需要載入大量 JavaScript 檔案。API 端點只是 JSON 回應，不需要載入任何 JS，所以不受 CSP 影響。\nQ2: 加了 'unsafe-inline' 還是不行？ 可能原因：\n引號問題\n// ❌ 錯誤：缺少引號 \u0026#39;script-src\u0026#39;: [\u0026#39;self\u0026#39;, \u0026#39;unsafe-inline\u0026#39;] // ✅ 正確：必須加引號 \u0026#39;script-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#34;\u0026#39;unsafe-inline\u0026#39;\u0026#34;] 設定沒有生效\n# 確認 Pod 已使用新的設定 kubectl delete pod -l app=strapi -n default # 檢查 CSP Header curl -I https://cms.example.com/admin | grep -i content-security Nginx/Ingress 覆蓋了設定\n# 檢查 Ingress annotations kubectl get ingress strapi-ingress -o yaml | grep -A 10 annotations Q3: 可以完全關閉 CSP 嗎？ 可以，但不推薦：\nmodule.exports = [ \u0026#39;strapi::logger\u0026#39;, \u0026#39;strapi::errors\u0026#39;, { name: \u0026#39;strapi::security\u0026#39;, config: { contentSecurityPolicy: false, // 完全關閉 CSP }, }, // ... ]; 風險：\n容易遭受 XSS 攻擊 不符合安全最佳實踐 可能違反資安稽核要求 Q4: 如何為不同環境設定不同的 CSP？ const isDevelopment = process.env.NODE_ENV === \u0026#39;development\u0026#39;; const isProduction = process.env.NODE_ENV === \u0026#39;production\u0026#39;; const cspDirectives = { development: { \u0026#39;script-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#34;\u0026#39;unsafe-inline\u0026#39;\u0026#34;, \u0026#34;\u0026#39;unsafe-eval\u0026#39;\u0026#34;, \u0026#39;cdn.jsdelivr.net\u0026#39;], \u0026#39;connect-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;https:\u0026#39;, \u0026#39;http:\u0026#39;, \u0026#39;ws:\u0026#39;, \u0026#39;wss:\u0026#39;], }, production: { \u0026#39;script-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#34;\u0026#39;unsafe-inline\u0026#39;\u0026#34;, \u0026#39;cdn.jsdelivr.net\u0026#39;], \u0026#39;connect-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;, \u0026#39;https:\u0026#39;], }, }; module.exports = [ { name: \u0026#39;strapi::security\u0026#39;, config: { contentSecurityPolicy: { useDefaults: true, directives: isProduction ? cspDirectives.production : cspDirectives.development, }, }, }, // ... ]; Q5: 如何測試 CSP 設定而不阻擋？ 使用 Content-Security-Policy-Report-Only Header：\nmodule.exports = { name: \u0026#39;strapi::security\u0026#39;, config: { contentSecurityPolicy: { useDefaults: true, reportOnly: true, // 只報告，不阻擋 directives: { \u0026#39;script-src\u0026#39;: [\u0026#34;\u0026#39;self\u0026#39;\u0026#34;], \u0026#39;report-uri\u0026#39;: [\u0026#39;/csp-violation-report\u0026#39;], }, }, }, }; 這樣可以在不影響功能的情況下，收集違規報告並調整設定。\n總結與收穫 關鍵要點 問題診斷：\n✅ 空白頁不一定是程式錯誤：可能是安全策略阻擋 ✅ 瀏覽器 DevTools 是最佳夥伴：Console 和 Network 標籤能揭露真相 ✅ 環境差異要注意：開發和正式環境的預設設定可能完全不同 CSP 理解：\n✅ CSP 是重要的安全機制：防止 XSS 和程式碼注入攻擊 ✅ 'self' 必須加引號：CSP 語法規定 ✅ 'unsafe-inline' 並非絕對不安全：對管理後台來說是可接受的 ✅ 最小權限原則：只開放必要的來源 Strapi 配置：\n✅ config/middlewares.js 是關鍵：控制中介層行為 ✅ useDefaults: true 保留基本設定：只覆蓋需要調整的部分 ✅ 環境變數靈活控制：不同環境使用不同策略 檢查清單 部署前：\n確認 config/middlewares.js 設定正確 本地測試 production 模式 檢查所有外部資源來源 確認環境變數設定 部署後：\n檢查 Pod 狀態和日誌 測試 API 端點 測試 Admin Panel 檢查 CSP Header 瀏覽器 Console 無錯誤 功能測試通過 安全性：\n不使用 'unsafe-eval' 明確指定可信來源 設定 CSP 違規報告 定期檢查安全更新 考慮分離 Admin 和 API 延伸閱讀 這次的經驗讓我深刻體會到：\n環境變數的影響比想像中大：一個簡單的 NODE_ENV=production 會觸發許多隱藏的行為變化 安全性與可用性的平衡：CSP 提供了重要的保護，但需要正確配置才不會影響功能 瀏覽器工具的重要性：DevTools 是前端開發者最好的朋友 希望這篇分享能幫助你少走一些彎路！🚀\n參考資源 官方文件：\nStrapi Security Middleware MDN - Content Security Policy (CSP) CSP Evaluator - Google 的 CSP 評估工具 實用工具：\nCSP Generator - 線上 CSP 產生器 CSP Validator - 驗證 CSP 設定 SecurityHeaders.com - 檢查網站安全 Headers 延伸閱讀：\nOWASP CSP Cheat Sheet Strapi Production Deployment Best Practices ","permalink":"https://peter-notes.com/posts/strapi-cms-blank-page-production/","summary":"\u003ch2 id=\"前言一個簡單的環境變數引發的災難\"\u003e前言：一個簡單的環境變數引發的災難\u003c/h2\u003e\n\u003cp\u003e在部署 Strapi CMS 到 Kubernetes 正式環境時，只是加了一行看似無害的環境變數設定：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003eenv\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e- \u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eNODE_ENV\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"nt\"\u003evalue\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eproduction \u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"c\"\u003e# 就是這一行！\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e結果卻導致整個管理後台變成一片空白，連登入頁面都看不到。更詭異的是：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e✅ API 完全正常，GraphQL 和 REST 都能回應\u003c/li\u003e\n\u003cli\u003e✅ Pod 狀態正常，沒有任何錯誤訊息\u003c/li\u003e\n\u003cli\u003e✅ 日誌顯示 Strapi 成功啟動\u003c/li\u003e\n\u003cli\u003e❌ 瀏覽器打開 \u003ccode\u003e/admin\u003c/code\u003e 卻是一片空白\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e這種「Schrodinger 的服務」（同時正常又不正常）讓人抓狂。經過一番排查，終於發現罪魁禍首是 \u003cstrong\u003eCSP (Content Security Policy)\u003c/strong\u003e 在作怪。\u003c/p\u003e\n\u003cp\u003e本文將深入探討：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e為什麼正式環境會出現空白頁\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCSP 的工作原理與安全機制\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e完整的問題排查步驟\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e如何正確配置 Strapi 的安全策略\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e生產環境的安全最佳實踐\u003c/strong\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"問題背景開發正常正式環境空白\"\u003e問題背景：開發正常，正式環境空白\u003c/h2\u003e\n\u003ch3 id=\"環境差異對比\"\u003e環境差異對比\u003c/h3\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/2025-05-07-解決-strapi-cms-正式環境空白頁的踩坑經驗分享-diagram-a37e3baa.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003ch3 id=\"問題現象詳細描述\"\u003e問題現象詳細描述\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eKubernetes Deployment 設定：\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003eapiVersion\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eapps/v1\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003ekind\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eDeployment\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003emetadata\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003estrapi-prod\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003enamespace\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003edefault\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003espec\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003ereplicas\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"m\"\u003e1\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003eselector\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"nt\"\u003ematchLabels\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e      \u003c/span\u003e\u003cspan class=\"nt\"\u003eapp\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003estrapi\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003etemplate\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"nt\"\u003emetadata\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e      \u003c/span\u003e\u003cspan class=\"nt\"\u003elabels\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"nt\"\u003eapp\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003estrapi\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"nt\"\u003espec\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e      \u003c/span\u003e\u003cspan class=\"nt\"\u003econtainers\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e      \u003c/span\u003e- \u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003estrapi\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"nt\"\u003eimage\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003emyregistry.com/strapi:v5.0.0\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"nt\"\u003eenv\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e          \u003c/span\u003e- \u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eNODE_ENV\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e            \u003c/span\u003e\u003cspan class=\"nt\"\u003evalue\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eproduction \u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"c\"\u003e# 問題的起點\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e          \u003c/span\u003e- \u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eDATABASE_HOST\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e            \u003c/span\u003e\u003cspan class=\"nt\"\u003evalueFrom\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e              \u003c/span\u003e\u003cspan class=\"nt\"\u003esecretKeyRef\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                \u003c/span\u003e\u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003estrapi-db-secret\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                \u003c/span\u003e\u003cspan class=\"nt\"\u003ekey\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003ehost\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e          \u003c/span\u003e- \u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eADMIN_JWT_SECRET\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e            \u003c/span\u003e\u003cspan class=\"nt\"\u003evalueFrom\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e              \u003c/span\u003e\u003cspan class=\"nt\"\u003esecretKeyRef\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                \u003c/span\u003e\u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003estrapi-admin-secret\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                \u003c/span\u003e\u003cspan class=\"nt\"\u003ekey\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003ejwt-secret\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"nt\"\u003eports\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e          \u003c/span\u003e- \u003cspan class=\"nt\"\u003econtainerPort\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"m\"\u003e1337\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e部署後的症狀：\u003c/strong\u003e\u003c/p\u003e","title":"解決 Strapi CMS 正式環境空白頁的踩坑經驗分享"},{"content":"前言：一次神秘的 Pod 複製事件 在一次例行的 Strapi CMS 更新部署到 AWS EKS 時，遇到了一個詭異的現象：明明 Deployment 設定檔中清楚寫著 replicas: 1，但實際運行的 Pod 卻有兩個！更奇怪的是，其中一個 Pod 持續處於 CrashLoopBackOff 狀態，而另一個則正常運行。\n無論怎麼刪除多餘的 Pod，它總是會像打不死的蟑螂一樣再次出現。這種「靈異事件」讓我開始懷疑 Kubernetes 是不是有自己的想法\u0026hellip;\n本文將深入探討：\n為什麼會出現多餘的 Pod CrashLoopBackOff 背後的機制 Kubernetes Deployment 和 ReplicaSet 的運作原理 實戰排查步驟與解決方案 Secret 編碼陷阱與預防措施 問題背景：Deployment 說一個，實際卻有兩個 問題現象 預期行為：\n# my-strapi-prod-deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: my-strapi-prod spec: replicas: 1 # 只要 1 個 Pod selector: matchLabels: app: my-strapi-prod 實際情況：\n$ kubectl get pods -n default | grep my-strapi-prod NAME READY STATUS RESTARTS AGE my-strapi-prod-7d4b5c8f9d-x2k4p 1/1 Running 0 10m my-strapi-prod-8c9a6d7e5f-w8n2q 0/1 CrashLoopBackOff 5 5m 兩個 Pod 同時存在，卻只有一個正常運行！\n問題時間線 核心概念：理解 Kubernetes Deployment 機制 在排查問題之前，先來理解 Kubernetes 是如何管理 Pod 的。\nDeployment、ReplicaSet 與 Pod 的關係 層級說明：\nDeployment：部署控制器\n負責管理應用的整個生命週期 創建和管理 ReplicaSet 支援滾動更新（Rolling Update） ReplicaSet：副本集\n確保指定數量的 Pod 副本在運行 每次 Deployment 更新都會創建新的 ReplicaSet 舊的 ReplicaSet 不會立即刪除（用於回滾） Pod：最小部署單元\n實際運行容器的地方 由 ReplicaSet 創建和管理 滾動更新機制 當你更新 Deployment 時，Kubernetes 會執行「滾動更新」：\n關鍵機制：安全保護\nKubernetes 的滾動更新有一個重要的安全機制：\n只有當新 Pod 通過健康檢查（Readiness Probe）後，才會刪除舊 Pod 如果新 Pod 一直無法啟動，舊 Pod 會繼續運行 這就是為什麼會出現「兩個 Pod 並存」的現象！ 問題排查：一步步找出根本原因 步驟 1：確認 Pod 狀態 # 查看所有 Pod 狀態 kubectl get pods --all-namespaces | grep my-strapi-prod 輸出結果：\nNAMESPACE NAME READY STATUS RESTARTS AGE default my-strapi-prod-7d4b5c8f9d-x2k4p 1/1 Running 0 10m default my-strapi-prod-8c9a6d7e5f-w8n2q 0/1 CrashLoopBackOff 5 5m 關鍵資訊：\n兩個 Pod 的名稱中間部分不同（7d4b5c8f9d vs 8c9a6d7e5f） 這些是 ReplicaSet 的 hash，代表它們來自不同的 ReplicaSet 步驟 2：檢查 ReplicaSet # 查看所有 ReplicaSet kubectl get rs -n default | grep my-strapi-prod 輸出結果：\nNAME DESIRED CURRENT READY AGE my-strapi-prod-7d4b5c8f9d 1 1 1 10m my-strapi-prod-8c9a6d7e5f 1 1 0 5m 發現問題！\n存在兩個 ReplicaSet 同時運行 舊 ReplicaSet（7d4b5c8f9d）：1 個 Pod Ready 新 ReplicaSet（8c9a6d7e5f）：1 個 Pod 但 0 個 Ready 步驟 3：查看 Pod 日誌找出 Crash 原因 # 查看失敗 Pod 的日誌 kubectl logs my-strapi-prod-8c9a6d7e5f-w8n2q -n default 日誌輸出：\n[2025-05-06 10:23:45.123] ERROR: Failed to connect to database [2025-05-06 10:23:45.124] Error: password authentication failed for user \u0026#34;strapi\u0026#34; [2025-05-06 10:23:45.125] INFO: Retrying in 5 seconds... 找到根本原因： 資料庫密碼錯誤！\n步驟 4：檢查 Secret 設定 # 查看 Secret kubectl get secret strapi-db-secret -n default -o yaml 發現問題：\napiVersion: v1 kind: Secret metadata: name: strapi-db-secret data: DB_PASSWORD: bXlwYXNzd29yZAo= # ❌ 多了換行符號 \\n 解碼驗證：\n$ echo \u0026#34;bXlwYXNzd29yZAo=\u0026#34; | base64 -d | xxd 00000000: 6d79 7061 7373 776f 7264 0a mypassword. # 最後的 0a 就是 \\n (換行符號) CrashLoopBackOff 狀態深度解析 什麼是 CrashLoopBackOff？ CrashLoopBackOff 是 Kubernetes 的一種 Pod 狀態，表示：\nPod 啟動後立即崩潰（Crash） Kubernetes 嘗試重啟 Pod（Loop） 每次重啟失敗後，等待時間會遞增（Backoff） 重啟等待時間演算法：\n首次重啟：立即 第二次：10 秒 第三次：20 秒 第四次：40 秒 第五次：80 秒 最大值：5 分鐘 CrashLoopBackOff 流程圖 常見的 CrashLoopBackOff 原因 原因類型 具體問題 排查指令 設定錯誤 環境變數、Secret、ConfigMap 錯誤 kubectl logs \u0026lt;pod\u0026gt; 資源不足 記憶體 OOM、CPU 限制 kubectl describe pod \u0026lt;pod\u0026gt; 映像檔問題 映像檔不存在、拉取失敗 kubectl describe pod \u0026lt;pod\u0026gt; 健康檢查 Liveness Probe 設定過於嚴格 查看 Deployment YAML 應用程式錯誤 程式碼 bug、依賴缺失 kubectl logs \u0026lt;pod\u0026gt; 權限問題 無法存取 Volume、Secret kubectl describe pod \u0026lt;pod\u0026gt; 解決方案：從錯誤到修復 嘗試方案 1：修正 Secret（失敗） 最直接的想法是修正資料庫密碼的 Secret。\n正確的 base64 編碼方式：\n# ❌ 錯誤：會包含換行符號 echo \u0026#34;mypassword\u0026#34; | base64 # 輸出：bXlwYXNzd29yZAo= (多了 \\n) # ✅ 正確：不包含換行符號 echo -n \u0026#34;mypassword\u0026#34; | base64 # 輸出：bXlwYXNzd29yZA== # ✅ 或使用 printf printf \u0026#34;mypassword\u0026#34; | base64 # 輸出：bXlwYXNzd29yZA== 更新 Secret：\n# 方法 1：直接編輯 kubectl edit secret strapi-db-secret -n default # 方法 2：重新建立 kubectl create secret generic strapi-db-secret \\ --from-literal=DB_PASSWORD=mypassword \\ --dry-run=client -o yaml | kubectl apply -f - 刪除 Pod 讓它重建：\nkubectl delete pod -l app=my-strapi-prod -n default 結果： 新 Pod 啟動後還是 crash！檢查日誌後發現可能還有其他設定錯誤\u0026hellip;\n嘗試方案 2：Rollback 到上一版本（成功！） 既然新版本有問題，不如先回到穩定版本。\n查看 Deployment 歷史：\nkubectl rollout history deployment my-strapi-prod -n default 輸出：\nREVISION CHANGE-CAUSE 1 Initial deployment 2 Update Strapi to v4.15.0 3 Fix database configuration (current, failing) 執行回滾：\n# 回滾到上一個版本 kubectl rollout undo deployment my-strapi-prod -n default # 或回滾到特定版本 kubectl rollout undo deployment my-strapi-prod --to-revision=2 -n default 查看回滾狀態：\nkubectl rollout status deployment my-strapi-prod -n default 輸出：\nWaiting for deployment \u0026#34;my-strapi-prod\u0026#34; rollout to finish: 1 old replicas are pending termination... Waiting for deployment \u0026#34;my-strapi-prod\u0026#34; rollout to finish: 1 old replicas are pending termination... deployment \u0026#34;my-strapi-prod\u0026#34; successfully rolled out 確認結果：\nkubectl get pods -n default | grep my-strapi-prod 輸出：\nNAME READY STATUS RESTARTS AGE my-strapi-prod-7d4b5c8f9d-n5r8t 1/1 Running 0 2m 只剩下 1 個 Pod 了！✅\n深入理解：為什麼回滾會成功？ Rollback 流程詳解 關鍵點：\nRollback 不是「恢復」，而是「前進到舊版本」 實際上是創建一個新的 Revision，內容等同於舊版本 Kubernetes 會保留所有的 ReplicaSet 歷史（預設 10 個） ReplicaSet 保留機制 # 查看所有 ReplicaSet（包含已縮減到 0 的） kubectl get rs -n default # 輸出範例 NAME DESIRED CURRENT READY AGE my-strapi-prod-7d4b5c8f9d 1 1 1 20m ← 當前運行 my-strapi-prod-8c9a6d7e5f 0 0 0 15m ← 失敗的版本 my-strapi-prod-6a2b3c4d5e 0 0 0 2h ← 更早的版本 為什麼保留舊的 ReplicaSet？\n快速回滾：不需要重新拉取映像檔或重新建立資源 歷史記錄：可以查看每個版本的設定 漸進式部署：可以手動控制流量分配（Canary Deployment） 設定保留數量：\napiVersion: apps/v1 kind: Deployment metadata: name: my-strapi-prod spec: revisionHistoryLimit: 10 # 保留 10 個歷史版本（預設值） replicas: 1 # ... Secret Base64 編碼陷阱與最佳實踐 為什麼 echo 會加入換行符號？ # echo 預設會加入換行符號 $ echo \u0026#34;password\u0026#34; | xxd 00000000: 7061 7373 776f 7264 0a password. ^^ 這是 \\n (0x0a) # echo -n 抑制換行符號 $ echo -n \u0026#34;password\u0026#34; | xxd 00000000: 7061 7373 776f 7264 password Secret 建立的最佳實踐 方法 1：使用 \u0026ndash;from-literal（推薦）\nkubectl create secret generic my-secret \\ --from-literal=username=admin \\ --from-literal=password=mypassword \\ -n default 方法 2：使用 \u0026ndash;from-file\n# 將密碼寫入檔案（不含換行） printf \u0026#34;mypassword\u0026#34; \u0026gt; password.txt # 從檔案建立 Secret kubectl create secret generic my-secret \\ --from-file=password=password.txt \\ -n default # 記得刪除明文密碼檔案 rm password.txt 方法 3：使用 \u0026ndash;from-env-file\n# .env 檔案 cat \u0026lt;\u0026lt;EOF \u0026gt; secret.env USERNAME=admin PASSWORD=mypassword DB_HOST=postgres.example.com EOF # 從 env 檔案建立 kubectl create secret generic my-secret \\ --from-env-file=secret.env \\ -n default rm secret.env 方法 4：手動編寫 YAML（最靈活）\n# 先產生 base64 值 DB_PASSWORD=$(echo -n \u0026#34;mypassword\u0026#34; | base64) # 建立 YAML cat \u0026lt;\u0026lt;EOF | kubectl apply -f - apiVersion: v1 kind: Secret metadata: name: my-secret namespace: default type: Opaque data: DB_PASSWORD: ${DB_PASSWORD} EOF Secret 驗證方法 # 方法 1：解碼 Secret 內容 kubectl get secret my-secret -n default -o jsonpath=\u0026#39;{.data.DB_PASSWORD}\u0026#39; | base64 -d # 應該輸出：mypassword (不含換行) # 方法 2：檢查 hex dump kubectl get secret my-secret -n default -o jsonpath=\u0026#39;{.data.DB_PASSWORD}\u0026#39; | base64 -d | xxd # 確認最後沒有 0a (換行符號) # 方法 3：在 Pod 中驗證 kubectl run debug-pod --rm -it --image=busybox --restart=Never -- sh # 在 Pod 中 echo $DB_PASSWORD | xxd 實用的 kubectl 排查指令集 查看資源狀態 # 查看 Pod 詳細資訊（包含事件） kubectl describe pod \u0026lt;pod-name\u0026gt; -n \u0026lt;namespace\u0026gt; # 查看 Pod 日誌 kubectl logs \u0026lt;pod-name\u0026gt; -n \u0026lt;namespace\u0026gt; # 查看之前崩潰的 Pod 日誌 kubectl logs \u0026lt;pod-name\u0026gt; -n \u0026lt;namespace\u0026gt; --previous # 即時查看日誌（類似 tail -f） kubectl logs -f \u0026lt;pod-name\u0026gt; -n \u0026lt;namespace\u0026gt; # 查看特定容器的日誌（多容器 Pod） kubectl logs \u0026lt;pod-name\u0026gt; -c \u0026lt;container-name\u0026gt; -n \u0026lt;namespace\u0026gt; Deployment 管理 # 查看 Deployment 狀態 kubectl get deployment \u0026lt;deployment-name\u0026gt; -n \u0026lt;namespace\u0026gt; # 查看 Deployment 詳情 kubectl describe deployment \u0026lt;deployment-name\u0026gt; -n \u0026lt;namespace\u0026gt; # 查看 Rollout 歷史 kubectl rollout history deployment \u0026lt;deployment-name\u0026gt; -n \u0026lt;namespace\u0026gt; # 查看特定 Revision 的詳情 kubectl rollout history deployment \u0026lt;deployment-name\u0026gt; --revision=2 # 暫停 Rollout（用於多次修改後一次部署） kubectl rollout pause deployment \u0026lt;deployment-name\u0026gt; -n \u0026lt;namespace\u0026gt; # 恢復 Rollout kubectl rollout resume deployment \u0026lt;deployment-name\u0026gt; -n \u0026lt;namespace\u0026gt; # 查看 Rollout 狀態 kubectl rollout status deployment \u0026lt;deployment-name\u0026gt; -n \u0026lt;namespace\u0026gt; ReplicaSet 管理 # 查看所有 ReplicaSet kubectl get rs -n \u0026lt;namespace\u0026gt; # 查看特定 Deployment 的 ReplicaSet kubectl get rs -n \u0026lt;namespace\u0026gt; -l app=\u0026lt;app-label\u0026gt; # 手動調整 ReplicaSet（不推薦，應該透過 Deployment） kubectl scale rs \u0026lt;replicaset-name\u0026gt; --replicas=0 -n \u0026lt;namespace\u0026gt; 強制刪除卡住的資源 # 強制刪除 Pod（當 Pod 卡在 Terminating 狀態） kubectl delete pod \u0026lt;pod-name\u0026gt; --grace-period=0 --force -n \u0026lt;namespace\u0026gt; # 刪除所有失敗的 Pod kubectl delete pods --field-selector=status.phase=Failed -n \u0026lt;namespace\u0026gt; # 刪除所有 CrashLoopBackOff 的 Pod kubectl get pods -n \u0026lt;namespace\u0026gt; | grep CrashLoopBackOff | awk \u0026#39;{print $1}\u0026#39; | xargs kubectl delete pod -n \u0026lt;namespace\u0026gt; 預防措施與最佳實踐 1. 設定健康檢查 正確設定 Liveness 和 Readiness Probe 可以讓 Kubernetes 更準確地判斷 Pod 狀態。\napiVersion: apps/v1 kind: Deployment metadata: name: my-strapi-prod spec: template: spec: containers: - name: strapi image: my-strapi:v1.0.0 ports: - containerPort: 1337 # Readiness Probe：Pod 是否準備好接收流量 readinessProbe: httpGet: path: /_health port: 1337 initialDelaySeconds: 30 # 啟動後等待 30 秒再檢查 periodSeconds: 10 # 每 10 秒檢查一次 timeoutSeconds: 5 # 請求超時時間 successThreshold: 1 # 成功 1 次即視為就緒 failureThreshold: 3 # 失敗 3 次才視為未就緒 # Liveness Probe：Pod 是否還活著 livenessProbe: httpGet: path: /_health port: 1337 initialDelaySeconds: 60 # 給足夠的啟動時間 periodSeconds: 30 # 不需要太頻繁 timeoutSeconds: 5 failureThreshold: 3 # 失敗 3 次才重啟 2. 設定資源限制 spec: containers: - name: strapi resources: requests: # 最小保證資源 memory: \u0026#34;512Mi\u0026#34; cpu: \u0026#34;250m\u0026#34; limits: # 最大可用資源 memory: \u0026#34;1Gi\u0026#34; cpu: \u0026#34;500m\u0026#34; 3. 使用 Init Containers 進行預先檢查 spec: initContainers: - name: wait-for-db image: busybox:1.35 command: [\u0026#39;sh\u0026#39;, \u0026#39;-c\u0026#39;] args: - | until nc -z postgres-service 5432; do echo \u0026#34;Waiting for PostgreSQL...\u0026#34; sleep 2 done echo \u0026#34;PostgreSQL is ready!\u0026#34; 4. 設定合理的 Rollout 策略 spec: strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 # 確保至少有 1 個 Pod 運行 maxSurge: 1 # 最多多出 1 個 Pod 5. 建立 CI/CD 流程驗證 # .gitlab-ci.yml deploy: script: # 部署後等待 rollout 完成 - kubectl apply -f deployment.yaml - kubectl rollout status deployment/my-app --timeout=5m # 如果失敗自動回滾 - | if [ $? -ne 0 ]; then echo \u0026#34;Deployment failed, rolling back...\u0026#34; kubectl rollout undo deployment/my-app exit 1 fi 總結與收穫 關鍵要點 技術層面：\n✅ Kubernetes 的滾動更新是漸進式的：新 Pod 就緒後才會刪除舊 Pod ✅ CrashLoopBackOff 是保護機制：避免無限快速重啟消耗資源 ✅ ReplicaSet 是中間層：Deployment 透過 ReplicaSet 管理 Pod ✅ rollout undo 是救命稻草：可以快速回到穩定版本 ✅ Secret base64 編碼要小心：使用 echo -n 或 --from-literal 排查思路：\n先查看 Pod 狀態 → kubectl get pods 檢查 ReplicaSet → kubectl get rs 查看 Pod 日誌 → kubectl logs 檢查設定 → kubectl describe 必要時回滾 → kubectl rollout undo 最佳實踐 開發階段：\n使用 --dry-run=client 預覽變更 本地測試 Secret 的 base64 編碼 設定合理的健康檢查 部署階段：\n使用 kubectl apply 而非 kubectl create 部署後監控 kubectl rollout status 保留足夠的 Revision 歷史 維運階段：\n定期檢查 kubectl get rs 清理無用的 ReplicaSet 監控 Pod 重啟次數 建立告警機制捕捉 CrashLoopBackOff 延伸思考 Q: 為什麼不直接刪除失敗的 ReplicaSet？ A: 因為它是由 Deployment 管理的。手動刪除後，Deployment 會立即重新創建它。要解決問題，應該修正 Deployment 設定或執行 rollback。\nQ: 可以同時運行多個版本的 Pod 嗎？ A: 可以！這就是 Canary Deployment 的原理。手動調整新舊 ReplicaSet 的副本數，就能控制流量分配（需要搭配 Service Mesh 或 Ingress）。\nQ: rollout undo 會影響正在運行的服務嗎？ A: 正常情況下不會，因為是滾動更新。但如果舊版本也有問題，或資源不足，可能會短暫中斷。\n參考資源 官方文件：\nKubernetes Deployments Kubernetes ReplicaSet Configure Liveness, Readiness and Startup Probes Secrets 實用工具：\nkubectl Cheat Sheet kubectx/kubens - 快速切換 context 和 namespace k9s - 強大的 Kubernetes CLI UI 延伸閱讀：\nUnderstanding Kubernetes Pod Lifecycle Kubernetes Best Practices ","permalink":"https://peter-notes.com/posts/k8s-crashloopbackoff-troubleshooting/","summary":"\u003ch2 id=\"前言一次神秘的-pod-複製事件\"\u003e前言：一次神秘的 Pod 複製事件\u003c/h2\u003e\n\u003cp\u003e在一次例行的 Strapi CMS 更新部署到 AWS EKS 時，遇到了一個詭異的現象：明明 Deployment 設定檔中清楚寫著 \u003ccode\u003ereplicas: 1\u003c/code\u003e，但實際運行的 Pod 卻有兩個！更奇怪的是，其中一個 Pod 持續處於 \u003ccode\u003eCrashLoopBackOff\u003c/code\u003e 狀態，而另一個則正常運行。\u003c/p\u003e\n\u003cp\u003e無論怎麼刪除多餘的 Pod，它總是會像打不死的蟑螂一樣再次出現。這種「靈異事件」讓我開始懷疑 Kubernetes 是不是有自己的想法\u0026hellip;\u003c/p\u003e\n\u003cp\u003e本文將深入探討：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e為什麼會出現多餘的 Pod\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCrashLoopBackOff 背後的機制\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eKubernetes Deployment 和 ReplicaSet 的運作原理\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e實戰排查步驟與解決方案\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSecret 編碼陷阱與預防措施\u003c/strong\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"問題背景deployment-說一個實際卻有兩個\"\u003e問題背景：Deployment 說一個，實際卻有兩個\u003c/h2\u003e\n\u003ch3 id=\"問題現象\"\u003e問題現象\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e預期行為：\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c\"\u003e# my-strapi-prod-deployment.yaml\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003eapiVersion\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eapps/v1\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003ekind\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003eDeployment\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003emetadata\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003emy-strapi-prod\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\u003c/span\u003e\u003cspan class=\"nt\"\u003espec\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003ereplicas\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"m\"\u003e1\u003c/span\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"c\"\u003e# 只要 1 個 Pod\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003eselector\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"nt\"\u003ematchLabels\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e      \u003c/span\u003e\u003cspan class=\"nt\"\u003eapp\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003emy-strapi-prod\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e實際情況：\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e$ kubectl get pods -n default \u003cspan class=\"p\"\u003e|\u003c/span\u003e grep my-strapi-prod\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eNAME                               READY   STATUS             RESTARTS   AGE\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003emy-strapi-prod-7d4b5c8f9d-x2k4p    1/1     Running            \u003cspan class=\"m\"\u003e0\u003c/span\u003e          10m\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003emy-strapi-prod-8c9a6d7e5f-w8n2q    0/1     CrashLoopBackOff   \u003cspan class=\"m\"\u003e5\u003c/span\u003e          5m\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e兩個 Pod 同時存在，卻只有一個正常運行！\u003c/p\u003e","title":"解決 Kubernetes 多餘 Pod 問題與 CrashLoopBackOff 的實戰心得"},{"content":"客製化UI \u0026amp; Components\nStrapi 5 客製化UI \u0026amp; Components\n發現:\nhttps://design-system.strapi.io/?path=/docs/getting-started-welcome\u0026ndash;docs\n在@strapi/design-system, 目前無法更改底層\nhttps://docs.strapi.io/dev-docs/customization\n“Some parts of the admin panel can be customized.”\n覆蓋樣式：使用自定義 CSS 或樣式覆蓋，而非直接改動源碼。(目前朝此方向研究) ","permalink":"https://peter-notes.com/posts/strapi-5-intro/","summary":"\u003cp\u003e客製化UI \u0026amp; Components\u003c/p\u003e\n\u003ch3 id=\"strapi-5\"\u003eStrapi 5\u003c/h3\u003e\n\u003cp\u003e客製化UI \u0026amp; Components\u003c/p\u003e\n\u003cp\u003e發現:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\n\u003cp\u003e\u003ca href=\"https://design-system.strapi.io/?path=/docs/getting-started-welcome--docs\"\u003ehttps://design-system.strapi.io/?path=/docs/getting-started-welcome\u0026ndash;docs\u003c/a\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e在\u003ca href=\"http://twitter.com/strapi/design-system\"\u003e@strapi/design-system\u003c/a\u003e, 目前無法更改底層\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003ca href=\"https://docs.strapi.io/dev-docs/customization\"\u003ehttps://docs.strapi.io/dev-docs/customization\u003c/a\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e“Some parts of the admin panel can be customized.”\u003c/p\u003e\n\u003col start=\"4\"\u003e\n\u003cli\u003e\u003cstrong\u003e覆蓋樣式\u003c/strong\u003e：使用自定義 CSS 或樣式覆蓋，而非直接改動源碼。(目前朝此方向研究)\u003c/li\u003e\n\u003c/ol\u003e","title":"Strapi 5"},{"content":"前言 在開發 Strapi 後台管理系統時，我遇到一個實際的使用者體驗問題：在「已購買課程」列表中，管理員可以用「真實姓名」搜尋到用戶的購課記錄，但用「電話號碼」卻搜尋不到。這個不一致的行為讓管理員感到困惑。\n本文將分享如何在 Strapi v5 中實作自訂搜尋邏輯，讓 Admin 面板支援跨關聯的多欄位搜尋。\n問題場景 資料結構：\npurchased-course（已購買課程）：關聯到 user user（用戶）：包含 email、realName、phoneNumber、username、nickname 等欄位 問題現象：\n✅ 搜尋「真實姓名」→ 可以找到對應的購課記錄 ❌ 搜尋「電話號碼」→ 找不到任何結果 為什麼會這樣？\n因為 Strapi 預設的搜尋功能只會搜尋當前 Collection 的直接欄位，不會自動搜尋關聯表（relation）的欄位。當我們在 purchased-course 列表搜尋時，Strapi 只會在 purchased-course 本身的欄位中搜尋，而不會去搜尋關聯的 user 資料。\n解決方案架構 Strapi 提供了三個層級可以自訂搜尋邏輯：\n1. Controller 層（API 端點） 位置： src/api/purchased-course/controllers/purchased-course.ts 用途： 處理前端透過 REST API 的搜尋請求\n2. Service 層（業務邏輯） 位置： src/api/purchased-course/services/purchased-course.ts 用途： 封裝可重複使用的業務邏輯\n3. Content Manager Plugin Extension（Admin 面板） 位置： src/extensions/content-manager/strapi-server.ts 用途： 最重要！ 這是讓 Admin 面板搜尋生效的關鍵\n完整實作 Step 1: 擴充 Content Manager Plugin 這是讓 Admin 面板搜尋功能生效的必要步驟：\n// src/extensions/content-manager/strapi-server.ts export default (plugin: any) =\u0026gt; { // 儲存原始的 find 方法 const originalFind = plugin.controllers[\u0026#39;collection-types\u0026#39;].find; // 覆寫 collection-types controller 的 find 方法 plugin.controllers[\u0026#39;collection-types\u0026#39;].find = async (ctx: any, next: any) =\u0026gt; { const { model } = ctx.params; const { query } = ctx.request; console.log(\u0026#39;🔍 [CM Override] Model:\u0026#39;, model); console.log(\u0026#39;🔍 [CM Override] Query:\u0026#39;, query); // 處理 purchased-course 的搜尋 if (model === \u0026#39;api::purchased-course.purchased-course\u0026#39; \u0026amp;\u0026amp; query._q) { const searchTerm = String(query._q); console.log(\u0026#39;🔍 [CM Override] Search term:\u0026#39;, searchTerm); // 搜尋用戶的多個欄位 const users: any = await strapi.entityService.findMany( \u0026#39;plugin::users-permissions.user\u0026#39;, { filters: { $or: [ { email: { $containsi: searchTerm } }, { realName: { $containsi: searchTerm } }, { phoneNumber: { $containsi: searchTerm } }, // 關鍵：加入電話號碼搜尋 { username: { $containsi: searchTerm } }, { nickname: { $containsi: searchTerm } }, ], }, fields: [\u0026#39;id\u0026#39;], } ); console.log(\u0026#39;👥 [CM Override] Found users:\u0026#39;, users); const userIds = Array.isArray(users) ? users.map((u: any) =\u0026gt; u.id) : []; if (userIds.length \u0026gt; 0) { console.log(\u0026#39;✅ [CM Override] Filtering by user IDs:\u0026#39;, userIds); // 將搜尋條件替換為「用戶 ID 篩選」 delete query._q; if (!query.filters) { query.filters = {}; } query.filters.user = { id: { $in: userIds, }, }; } else { console.log(\u0026#39;⚠️ [CM Override] No users found, return empty\u0026#39;); // 沒有找到用戶時，回傳空結果 delete query._q; query.filters = { id: { $in: [-1], // 不存在的 ID，確保回傳空結果 }, }; } console.log(\u0026#39;📤 [CM Override] Modified query:\u0026#39;, query); } // 呼叫原始的 find 方法 return await originalFind(ctx, next); }; return plugin; }; Step 2: 自訂 Controller（選用，支援 REST API） 如果你的前端使用 REST API 直接呼叫，也需要在 Controller 層加入相同邏輯：\n// src/api/purchased-course/controllers/purchased-course.ts import { factories } from \u0026#39;@strapi/strapi\u0026#39;; export default factories.createCoreController( \u0026#39;api::purchased-course.purchased-course\u0026#39;, ({ strapi }) =\u0026gt; ({ async find(ctx) { console.log(\u0026#39;🔍 Purchased Course Find - Query:\u0026#39;, ctx.query); const query: any = ctx.query; const searchQuery = query._q || query.filters?._q; if (searchQuery) { const searchTerm = String(searchQuery); console.log(\u0026#39;🔍 Search term:\u0026#39;, searchTerm); const users: any = await strapi.entityService.findMany( \u0026#39;plugin::users-permissions.user\u0026#39;, { filters: { $or: [ { email: { $containsi: searchTerm } }, { realName: { $containsi: searchTerm } }, { phoneNumber: { $containsi: searchTerm } }, { username: { $containsi: searchTerm } }, { nickname: { $containsi: searchTerm } }, ], }, fields: [\u0026#39;id\u0026#39;], } ); console.log(\u0026#39;👥 Found users:\u0026#39;, users); const userIds = Array.isArray(users) ? users.map((u: any) =\u0026gt; u.id) : []; if (userIds.length \u0026gt; 0) { delete query._q; if (query.filters) { delete query.filters._q; } if (!query.filters) { query.filters = {}; } query.filters.user = { id: { $in: userIds, }, }; } else { delete query._q; if (query.filters) { delete query.filters._q; } query.filters = { id: { $in: [-1], }, }; } } console.log(\u0026#39;📤 Final query:\u0026#39;, ctx.query); // 呼叫預設的核心方法 return await super.find(ctx); }, }) ); Step 3: Service 層（選用，重複使用邏輯） // src/api/purchased-course/services/purchased-course.ts import { factories } from \u0026#39;@strapi/strapi\u0026#39;; export default factories.createCoreService( \u0026#39;api::purchased-course.purchased-course\u0026#39;, ({ strapi }) =\u0026gt; ({ async find(params: any = {}) { console.log(\u0026#39;🔍 [Service] Purchased Course Find - Params:\u0026#39;, params); if (params._q || params.filters?._q) { const searchTerm = String(params._q || params.filters._q); const users: any = await strapi.entityService.findMany( \u0026#39;plugin::users-permissions.user\u0026#39;, { filters: { $or: [ { email: { $containsi: searchTerm } }, { realName: { $containsi: searchTerm } }, { phoneNumber: { $containsi: searchTerm } }, { username: { $containsi: searchTerm } }, { nickname: { $containsi: searchTerm } }, ], }, fields: [\u0026#39;id\u0026#39;], } ); const userIds = Array.isArray(users) ? users.map((u: any) =\u0026gt; u.id) : []; if (userIds.length \u0026gt; 0) { delete params._q; if (params.filters) { delete params.filters._q; } if (!params.filters) { params.filters = {}; } params.filters.user = { id: { $in: userIds, }, }; } else { delete params._q; if (params.filters) { delete params.filters._q; } params.filters = { id: { $in: [-1], }, }; } } return await super.find(params); }, }) ); 技術細節解析 1. $containsi 操作符 { phoneNumber: { $containsi: searchTerm } } $contains：大小寫敏感的包含搜尋 $containsi：大小寫不敏感（i = insensitive） 對應 PostgreSQL 的 ILIKE '%term%' 2. $or 邏輯運算 filters: { $or: [ { email: { $containsi: searchTerm } }, { realName: { $containsi: searchTerm } }, // ... ] } 只要符合任一條件即可回傳，實現多欄位搜尋。\n3. $in 操作符 query.filters.user = { id: { $in: userIds, // [85, 176, 96, 82, ...] }, }; 將搜尋轉換為「用戶 ID 清單篩選」，提升查詢效能。\n4. 空結果處理 query.filters = { id: { $in: [-1], // 不存在的 ID }, }; 當找不到符合的用戶時，使用不存在的 ID（-1）確保回傳空結果，而非所有記錄。\n驗證與測試 1. 資料庫層驗證 -- 檢查欄位型態 SELECT column_name, data_type, character_maximum_length FROM information_schema.columns WHERE table_name = \u0026#39;up_users\u0026#39; AND column_name = \u0026#39;phone_number\u0026#39;; -- 測試搜尋邏輯（對應 $containsi） SELECT id, real_name, phone_number FROM up_users WHERE phone_number ILIKE \u0026#39;%0987654321%\u0026#39;; 2. 查看 Console Log 啟動開發伺服器後，搜尋時會看到詳細的 log：\nnpm run develop 搜尋「0987654321」時的輸出：\n🔍 [CM Override] Model: api::purchased-course.purchased-course 🔍 [CM Override] Query: { page: \u0026#39;1\u0026#39;, pageSize: \u0026#39;10\u0026#39;, _q: \u0026#39;0987654321\u0026#39; } 🔍 [CM Override] Search term: 0987654321 👥 [CM Override] Found users: [ { id: 92, documentId: \u0026#39;xxx\u0026#39; } ] ✅ [CM Override] Filtering by user IDs: [ 92 ] 📤 [CM Override] Modified query: { page: \u0026#39;1\u0026#39;, pageSize: \u0026#39;10\u0026#39;, filters: { user: { id: { \u0026#39;$in\u0026#39;: [ 92 ] } } } } 3. Admin 面板測試 登入 Strapi Admin：http://localhost:1337/admin 進入「已購買課程」列表 在搜尋框輸入： ✅ 真實姓名：「張心慈」 ✅ 電話號碼：「0987654321」 ✅ Email：「teresa830907@gmail.com」 ✅ 用戶名稱或暱稱 全部都可以正常搜尋！\n常見問題 Q1: 為什麼需要三個地方都修改？ Content Manager Extension：Admin 面板搜尋（必須） Controller：REST API 搜尋（如果前端使用 API） Service：可重複使用的邏輯（如果其他地方也需要相同邏輯） Q2: 搜尋效能會不會有問題？ 目前的實作是：\n先查詢 users 表（使用索引） 取得用戶 ID 清單 再用 id IN (...) 查詢 purchased-courses 建議優化：\n在 phone_number、real_name 欄位建立索引 考慮使用 ElasticSearch 做全文搜尋（大量資料時） Q3: 如何支援更多關聯表搜尋？ 可以擴充 $or 條件，例如加入課程名稱搜尋：\n// 同時搜尋用戶和課程 const users = await strapi.entityService.findMany(\u0026#39;plugin::users-permissions.user\u0026#39;, { filters: { $or: [/* ... */] } }); const courses = await strapi.entityService.findMany(\u0026#39;api::course.course\u0026#39;, { filters: { title: { $containsi: searchTerm } } }); const userIds = users.map(u =\u0026gt; u.id); const courseIds = courses.map(c =\u0026gt; c.id); query.filters = { $or: [ { user: { id: { $in: userIds } } }, { course: { id: { $in: courseIds } } }, ] }; 總結 本文展示了如何在 Strapi v5 中實作自訂搜尋功能，關鍵要點：\n必須擴充 Content Manager Plugin 才能讓 Admin 面板搜尋生效 使用 $containsi 實現大小寫不敏感搜尋 將關聯表搜尋轉換為 ID 清單篩選，提升效能 善用 Console Log 除錯，確保邏輯正確 記得處理「找不到結果」的情況，避免回傳所有記錄 希望這篇文章能幫助你解決 Strapi 搜尋的困擾！\n","permalink":"https://peter-notes.com/posts/strapi-custom-search/","summary":"\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e在開發 Strapi 後台管理系統時，我遇到一個實際的使用者體驗問題：在「已購買課程」列表中，管理員可以用「真實姓名」搜尋到用戶的購課記錄，但用「電話號碼」卻搜尋不到。這個不一致的行為讓管理員感到困惑。\u003c/p\u003e\n\u003cp\u003e本文將分享如何在 Strapi v5 中實作自訂搜尋邏輯，讓 Admin 面板支援跨關聯的多欄位搜尋。\u003c/p\u003e\n\u003ch2 id=\"問題場景\"\u003e問題場景\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003e資料結構：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003epurchased-course\u003c/code\u003e（已購買課程）：關聯到 \u003ccode\u003euser\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003euser\u003c/code\u003e（用戶）：包含 \u003ccode\u003eemail\u003c/code\u003e、\u003ccode\u003erealName\u003c/code\u003e、\u003ccode\u003ephoneNumber\u003c/code\u003e、\u003ccode\u003eusername\u003c/code\u003e、\u003ccode\u003enickname\u003c/code\u003e 等欄位\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e問題現象：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e✅ 搜尋「真實姓名」→ 可以找到對應的購課記錄\u003c/li\u003e\n\u003cli\u003e❌ 搜尋「電話號碼」→ 找不到任何結果\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e為什麼會這樣？\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e因為 Strapi 預設的搜尋功能只會搜尋當前 Collection 的直接欄位，不會自動搜尋關聯表（relation）的欄位。當我們在 \u003ccode\u003epurchased-course\u003c/code\u003e 列表搜尋時，Strapi 只會在 \u003ccode\u003epurchased-course\u003c/code\u003e 本身的欄位中搜尋，而不會去搜尋關聯的 \u003ccode\u003euser\u003c/code\u003e 資料。\u003c/p\u003e\n\u003ch2 id=\"解決方案架構\"\u003e解決方案架構\u003c/h2\u003e\n\u003cp\u003eStrapi 提供了三個層級可以自訂搜尋邏輯：\u003c/p\u003e\n\u003ch3 id=\"1-controller-層api-端點\"\u003e1. Controller 層（API 端點）\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e位置：\u003c/strong\u003e \u003ccode\u003esrc/api/purchased-course/controllers/purchased-course.ts\u003c/code\u003e\n\u003cstrong\u003e用途：\u003c/strong\u003e 處理前端透過 REST API 的搜尋請求\u003c/p\u003e\n\u003ch3 id=\"2-service-層業務邏輯\"\u003e2. Service 層（業務邏輯）\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e位置：\u003c/strong\u003e \u003ccode\u003esrc/api/purchased-course/services/purchased-course.ts\u003c/code\u003e\n\u003cstrong\u003e用途：\u003c/strong\u003e 封裝可重複使用的業務邏輯\u003c/p\u003e\n\u003ch3 id=\"3-content-manager-plugin-extensionadmin-面板\"\u003e3. Content Manager Plugin Extension（Admin 面板）\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e位置：\u003c/strong\u003e \u003ccode\u003esrc/extensions/content-manager/strapi-server.ts\u003c/code\u003e\n\u003cstrong\u003e用途：\u003c/strong\u003e \u003cstrong\u003e最重要！\u003c/strong\u003e 這是讓 Admin 面板搜尋生效的關鍵\u003c/p\u003e","title":"Strapi 自訂搜尋功能：讓 Admin 面板支援多欄位搜尋"},{"content":"前言：一個讓人懷疑人生的 Pending 狀態 最近部署 Strapi CMS 到 AWS EKS 時，遇到一個詭異的情況：\n$ kubectl get pods -n default NAME READY STATUS RESTARTS AGE mycompany-strapi-prod-695854fbd4-dzw66 0/1 Pending 0 3h42m 一個 Pod 卡在 Pending 狀態超過三小時，CPU 和 Memory 明明還很充足，但就是起不來。\n如果你曾經盯著 kubectl get pods 看著那個永遠不會變成 Running 的 Pending 狀態，同時懷疑是不是 Kubernetes 在跟你開玩笑——恭喜你，你不孤單。\n在嘗試了 Google 前五個搜尋結果、檢查了三次 YAML 設定、並認真考慮是否該轉行當咖啡師之後，我終於找到了問題的根源\u0026hellip;\n⚠️ 劇透警告：問題的根源不是 CPU、不是 Memory，而是一個你可能從沒注意過的限制——網卡（ENI）和 IP 數量。\n問題診斷：一步步找出真兇 Step 1：查看 Pod 事件 遇到 Pending 狀態，第一步當然是看看 Kubernetes 到底在抱怨什麼：\n$ kubectl describe pod mycompany-strapi-prod-695854fbd4-dzw66 -n default 輸出內容很長，但最重要的是 Events 區塊：\nName: mycompany-strapi-prod-695854fbd4-dzw66 Namespace: default Priority: 0 Node: \u0026lt;none\u0026gt; Labels: app=strapi pod-template-hash=695854fbd4 Annotations: \u0026lt;none\u0026gt; Status: Pending IP: IPs: \u0026lt;none\u0026gt; Controlled By: ReplicaSet/mycompany-strapi-prod-695854fbd4 Containers: strapi: Image: mycompany/strapi:v1.2.3 Port: 1337/TCP Host Port: 0/TCP Requests: cpu: 250m memory: 512Mi Limits: cpu: 500m memory: 1Gi Events: Type Reason Age From Message ---- ------ ---- ---- ------- Warning FailedScheduling 3h default-scheduler 0/1 nodes are available: 1 Too many pods. 「Too many pods」？我只跑了十幾個 Pod，怎麼可能太多？\n這個錯誤訊息極具誤導性。它讓你以為是 Pod 數量的「軟限制」問題，實際上是硬性的網路限制。\nStep 2：檢查 Node 資源使用狀況 既然說 Pod 太多，那來看看 Node 的資源狀況：\n$ kubectl describe node ip-10-0-1-xxx.ap-northeast-1.compute.internal Name: ip-10-0-1-xxx.ap-northeast-1.compute.internal Roles: \u0026lt;none\u0026gt; Labels: beta.kubernetes.io/arch=amd64 beta.kubernetes.io/instance-type=t3.medium beta.kubernetes.io/os=linux kubernetes.io/arch=amd64 kubernetes.io/hostname=ip-10-0-1-xxx.ap-northeast-1.compute.internal kubernetes.io/os=linux node.kubernetes.io/instance-type=t3.medium topology.kubernetes.io/region=ap-northeast-1 topology.kubernetes.io/zone=ap-northeast-1a Allocated resources: (Total limits may be over 100 percent, i.e., overcommitted.) Resource Requests Limits -------- -------- ------ cpu 680m (35%) 1200m (61%) memory 756Mi (22%) 1536Mi (44%) ephemeral-storage 0 (0%) 0 (0%) hugepages-1Gi 0 (0%) 0 (0%) hugepages-2Mi 0 (0%) 0 (0%) CPU 使用 35%、Memory 使用 22%，資源明明還很充裕！\nStep 3：發現關鍵線索——Pod 容量限制 繼續往下看 Node 的詳細資訊：\n$ kubectl describe node \u0026lt;node-name\u0026gt; | grep -A 10 \u0026#34;Capacity\u0026#34; Capacity: attachable-volumes-aws-ebs: 25 cpu: 2 ephemeral-storage: 20959212Ki hugepages-1Gi: 0 hugepages-2Mi: 0 memory: 3943376Ki pods: 17 # 👈 注意這裡！ Allocatable: attachable-volumes-aws-ebs: 25 cpu: 1930m ephemeral-storage: 19316009748 hugepages-1Gi: 0 hugepages-2Mi: 0 memory: 3388496Ki pods: 17 # 👈 最大 Pod 數量是 17！ 再查看目前執行中的 Pod 數量：\n$ kubectl get pods -A --field-selector spec.nodeName=ip-10-0-1-xxx --no-headers | wc -l 17 真相大白：Node 上已經有 17 個 Pod，而這個 Node 的 Pod 上限剛好是 17！\n但問題來了：為什麼 t3.medium 這種看起來還不錯的 Instance，Pod 上限只有 17 個？\n深入理解：AWS VPC CNI 的運作機制 為什麼 EKS 要用 VPC CNI？ 在深入 ENI 限制之前，我們先了解 EKS 的網路架構選擇。\nKubernetes 本身不處理 Pod 網路，而是依賴 CNI（Container Network Interface） 插件。常見的 CNI 插件包括：\nCalico：使用 BGP 或 VXLAN 建立虛擬網路 Flannel：使用 VXLAN 或 host-gw 模式 Weave：使用自己的虛擬網路協定 AWS VPC CNI：直接使用 VPC 原生 IP AWS EKS 預設使用 AWS VPC CNI，它的特別之處在於：\n每個 Pod 都會獲得一個真實的 VPC IP 位址\nVPC CNI 的優缺點 優點：\n特性 說明 ✅ 原生 VPC 整合 Pod 可直接與 RDS、ElastiCache 等 VPC 資源通訊 ✅ 無需 NAT Pod IP 就是真實 VPC IP，不需要轉換 ✅ 安全群組支援 可以直接對 Pod 套用 Security Groups ✅ 網路效能 沒有 overlay 網路的額外開銷 ✅ 除錯簡單 IP 直接對應，方便追蹤網路問題 缺點：\n特性 說明 ❌ IP 數量限制 受限於 ENI 和 IP 配額 ❌ Subnet 規劃 需要足夠大的 CIDR 區塊 ❌ Pod 數量限制 比其他 CNI 方案更嚴格 ENI 是什麼？為什麼會有限制？ ENI（Elastic Network Interface） 是 AWS EC2 的虛擬網路卡。每個 ENI 可以：\n綁定一個 Primary IP（主要 IP） 綁定多個 Secondary IP（次要 IP） 附加到 EC2 Instance AWS 對每種 EC2 Instance 類型都有 ENI 和 IP 的限制，這些限制是硬性的，由底層硬體和網路架構決定。\nPod 數量計算公式 AWS VPC CNI 的 Pod 數量上限計算公式為：\nMax Pods = (ENI 數量 × 每個 ENI 的 IP 數) - ENI 數量 為什麼要減掉 ENI 數量？\n因為每個 ENI 的 Primary IP 是保留給 ENI 本身使用的，不能分配給 Pod。只有 Secondary IP 可以用於 Pod。\n以 t3.medium 為例 t3.medium 的限制： ├─ 最大 ENI 數量：3 └─ 每個 ENI 的 IPv4 數量：6 計算過程： ├─ 總 IP 數：3 × 6 = 18 個 ├─ Primary IP（保留）：3 個 └─ 可用於 Pod：18 - 3 = 15 個 實際上 EKS 顯示的是 17，這是因為： └─ AWS 有一些額外的優化和 CNI 版本差異 常見 Instance 類型的 Pod 限制 Instance 類型 vCPU Memory ENI 數量 每 ENI IP 數 Max Pods 適用場景 t3.micro 2 1 GB 2 2 4 學習/測試 t3.small 2 2 GB 3 4 11 小型開發 t3.medium 2 4 GB 3 6 17 開發/小型生產 t3.large 2 8 GB 3 12 35 中型生產 t3.xlarge 4 16 GB 4 15 58 大型生產 t3.2xlarge 8 32 GB 4 15 58 高負載生產 m5.large 2 8 GB 3 10 29 生產環境 m5.xlarge 4 16 GB 4 15 58 高負載生產 m5.2xlarge 8 32 GB 4 15 58 大型應用 c5.large 2 4 GB 3 10 29 運算密集 c5.xlarge 4 8 GB 4 15 58 高運算需求 r5.large 2 16 GB 3 10 29 記憶體密集 💡 完整清單：AWS 官方文件 - 每個 Instance 類型的 ENI 和 IP 限制\n視覺化診斷流程 當你遇到 Pod Pending 問題時，可以按照以下流程進行診斷：\n快速診斷指令 #!/bin/bash # quick-diagnosis.sh - EKS Pod Capacity 快速診斷腳本 echo \u0026#34;======================================\u0026#34; echo \u0026#34;EKS Pod Capacity 診斷報告\u0026#34; echo \u0026#34;======================================\u0026#34; echo \u0026#34;\u0026#34; # 取得所有 Node for node in $(kubectl get nodes -o name); do node_name=$(echo $node | cut -d\u0026#39;/\u0026#39; -f2) echo \u0026#34;📍 Node: $node_name\u0026#34; echo \u0026#34;-----------------------------------\u0026#34; # 取得 Instance 類型 instance_type=$(kubectl get $node -o jsonpath=\u0026#39;{.metadata.labels.node\\.kubernetes\\.io/instance-type}\u0026#39;) echo \u0026#34; Instance Type: $instance_type\u0026#34; # 取得 Pod 容量 max_pods=$(kubectl get $node -o jsonpath=\u0026#39;{.status.allocatable.pods}\u0026#39;) echo \u0026#34; Max Pods: $max_pods\u0026#34; # 取得目前 Pod 數 current_pods=$(kubectl get pods -A --field-selector spec.nodeName=$node_name --no-headers 2\u0026gt;/dev/null | wc -l) echo \u0026#34; Current Pods: $current_pods\u0026#34; # 計算使用率 if [ \u0026#34;$max_pods\u0026#34; -gt 0 ]; then usage=$((current_pods * 100 / max_pods)) echo \u0026#34; Usage: ${usage}%\u0026#34; # 警告訊息 if [ $usage -ge 90 ]; then echo \u0026#34; ⚠️ WARNING: Pod 容量即將耗盡！\u0026#34; elif [ $usage -ge 80 ]; then echo \u0026#34; ⚡ NOTICE: Pod 容量使用率較高\u0026#34; else echo \u0026#34; ✅ OK: Pod 容量充足\u0026#34; fi fi echo \u0026#34;\u0026#34; done echo \u0026#34;======================================\u0026#34; echo \u0026#34;診斷完成\u0026#34; echo \u0026#34;======================================\u0026#34; 執行結果範例：\n====================================== EKS Pod Capacity 診斷報告 ====================================== 📍 Node: ip-10-0-1-101.ap-northeast-1.compute.internal ----------------------------------- Instance Type: t3.medium Max Pods: 17 Current Pods: 17 Usage: 100% ⚠️ WARNING: Pod 容量即將耗盡！ 📍 Node: ip-10-0-1-102.ap-northeast-1.compute.internal ----------------------------------- Instance Type: t3.medium Max Pods: 17 Current Pods: 12 Usage: 70% ✅ OK: Pod 容量充足 ====================================== 診斷完成 ====================================== 五種解決方案詳解 方案一：水平擴展（增加 Node 數量） 適用場景：\n需要快速解決問題 預算允許 希望同時增加整體運算能力 使用 eksctl 擴展：\n# 查看目前的 Node Group eksctl get nodegroup --cluster=my-cluster # 擴展 Node 數量 eksctl scale nodegroup \\ --cluster=my-cluster \\ --name=my-nodegroup \\ --nodes=3 \\ --nodes-min=2 \\ --nodes-max=5 使用 AWS CLI 擴展：\naws eks update-nodegroup-config \\ --cluster-name my-cluster \\ --nodegroup-name my-nodegroup \\ --scaling-config minSize=2,maxSize=5,desiredSize=3 優缺點分析：\n優點 缺點 ✅ 最直接的解決方案 ❌ 成本增加 ✅ 同時增加運算能力 ❌ 可能造成資源浪費 ✅ 提高整體可用性 ❌ 需要更多管理 ✅ 無需修改應用程式 ❌ Subnet IP 消耗增加 方案二：垂直擴展（升級 Instance 類型） 適用場景：\n單個 Pod 資源需求大 希望在較少 Node 上運行更多 Pod 需要更高的單機效能 Instance 升級建議路徑：\n使用 eksctl 更新 Node Group：\n# nodegroup-config.yaml apiVersion: eksctl.io/v1alpha5 kind: ClusterConfig metadata: name: my-cluster region: ap-northeast-1 managedNodeGroups: - name: production-ng instanceType: t3.large # 從 t3.medium 升級 desiredCapacity: 2 minSize: 1 maxSize: 4 volumeSize: 50 volumeType: gp3 labels: role: production environment: prod tags: Owner: DevOps Project: MyApp iam: withAddonPolicies: autoScaler: true cloudWatch: true # 建立新的 Node Group eksctl create nodegroup -f nodegroup-config.yaml # 刪除舊的 Node Group（確保 Pod 已遷移後） eksctl delete nodegroup \\ --cluster=my-cluster \\ --name=old-nodegroup 方案三：啟用 Cluster Autoscaler 適用場景：\nPod 數量會動態變化 希望自動調整成本 需要應對流量高峰 架構圖：\nStep 1：設定 Node Group Tags\n# 在 AWS Console 或使用 CLI 為 Auto Scaling Group 加上這些 tag aws autoscaling create-or-update-tags \\ --tags \\ \u0026#34;ResourceId=my-asg-name,ResourceType=auto-scaling-group,Key=k8s.io/cluster-autoscaler/enabled,Value=true,PropagateAtLaunch=true\u0026#34; \\ \u0026#34;ResourceId=my-asg-name,ResourceType=auto-scaling-group,Key=k8s.io/cluster-autoscaler/my-cluster,Value=owned,PropagateAtLaunch=true\u0026#34; Step 2：部署 Cluster Autoscaler\n# cluster-autoscaler.yaml apiVersion: apps/v1 kind: Deployment metadata: name: cluster-autoscaler namespace: kube-system labels: app: cluster-autoscaler spec: replicas: 1 selector: matchLabels: app: cluster-autoscaler template: metadata: labels: app: cluster-autoscaler spec: serviceAccountName: cluster-autoscaler containers: - name: cluster-autoscaler image: registry.k8s.io/autoscaling/cluster-autoscaler:v1.28.0 command: - ./cluster-autoscaler - --v=4 - --stderrthreshold=info - --cloud-provider=aws - --skip-nodes-with-local-storage=false - --expander=least-waste - --node-group-auto-discovery=asg:tag=k8s.io/cluster-autoscaler/enabled,k8s.io/cluster-autoscaler/my-cluster - --balance-similar-node-groups - --skip-nodes-with-system-pods=false resources: limits: cpu: 100m memory: 600Mi requests: cpu: 100m memory: 600Mi env: - name: AWS_REGION value: ap-northeast-1 kubectl apply -f cluster-autoscaler.yaml Step 3：建立 IAM 權限\n{ \u0026#34;Version\u0026#34;: \u0026#34;2012-10-17\u0026#34;, \u0026#34;Statement\u0026#34;: [ { \u0026#34;Effect\u0026#34;: \u0026#34;Allow\u0026#34;, \u0026#34;Action\u0026#34;: [ \u0026#34;autoscaling:DescribeAutoScalingGroups\u0026#34;, \u0026#34;autoscaling:DescribeAutoScalingInstances\u0026#34;, \u0026#34;autoscaling:DescribeLaunchConfigurations\u0026#34;, \u0026#34;autoscaling:DescribeScalingActivities\u0026#34;, \u0026#34;autoscaling:DescribeTags\u0026#34;, \u0026#34;autoscaling:SetDesiredCapacity\u0026#34;, \u0026#34;autoscaling:TerminateInstanceInAutoScalingGroup\u0026#34;, \u0026#34;ec2:DescribeLaunchTemplateVersions\u0026#34;, \u0026#34;ec2:DescribeInstanceTypes\u0026#34; ], \u0026#34;Resource\u0026#34;: \u0026#34;*\u0026#34; } ] } 方案四：啟用 VPC CNI Prefix Delegation（進階） 適用場景：\n需要在同一個 Node 上運行大量小型 Pod 希望最大化單一 Node 的 Pod 密度 Subnet 有足夠的 IP 空間 什麼是 Prefix Delegation？\n原本 VPC CNI 是為每個 Pod 分配單一 IP，Prefix Delegation 改為分配一個 /28 的 IP 前綴（16 個 IP）給每個 ENI slot。\n比較：\n模式 每個 ENI slot t3.medium Max Pods 傳統模式 1 個 IP 17 Prefix Delegation 16 個 IP (/28) 110 啟用步驟：\n# Step 1: 更新 VPC CNI 到最新版本 kubectl describe daemonset aws-node -n kube-system | grep Image # Step 2: 啟用 Prefix Delegation kubectl set env daemonset aws-node \\ -n kube-system \\ ENABLE_PREFIX_DELEGATION=true # Step 3: 設定 warm pool（預先分配的 IP prefix 數量） kubectl set env daemonset aws-node \\ -n kube-system \\ WARM_PREFIX_TARGET=1 # Step 4: 驗證設定 kubectl get ds aws-node -n kube-system -o yaml | grep -A 5 \u0026#34;env:\u0026#34; 注意事項：\n⚠️ 重要考量：\n需要 VPC CNI 版本 1.9.0 以上 需要 Nitro-based Instance（大多數新型 Instance 都支援） Subnet 需要足夠大的 CIDR（建議至少 /19） 現有 Node 需要 drain 並重新加入 驗證腳本：\n#!/bin/bash # verify-prefix-delegation.sh echo \u0026#34;檢查 VPC CNI 設定...\u0026#34; # 檢查 CNI 版本 cni_version=$(kubectl get ds aws-node -n kube-system -o jsonpath=\u0026#39;{.spec.template.spec.containers[0].image}\u0026#39; | grep -oP \u0026#39;v\\d+\\.\\d+\\.\\d+\u0026#39;) echo \u0026#34;VPC CNI 版本: $cni_version\u0026#34; # 檢查 Prefix Delegation 狀態 prefix_enabled=$(kubectl get ds aws-node -n kube-system -o jsonpath=\u0026#39;{.spec.template.spec.containers[0].env[?(@.name==\u0026#34;ENABLE_PREFIX_DELEGATION\u0026#34;)].value}\u0026#39;) echo \u0026#34;Prefix Delegation: ${prefix_enabled:-未啟用}\u0026#34; # 檢查 Node 的 max-pods for node in $(kubectl get nodes -o name); do node_name=$(echo $node | cut -d\u0026#39;/\u0026#39; -f2) max_pods=$(kubectl get $node -o jsonpath=\u0026#39;{.status.allocatable.pods}\u0026#39;) echo \u0026#34;Node $node_name - Max Pods: $max_pods\u0026#34; done 方案五：使用 AWS Fargate（無伺服器） 適用場景：\nStateless 服務 測試環境 不想管理 Node 需要快速擴展 架構比較：\n建立 Fargate Profile：\n# fargate-profile.yaml apiVersion: eksctl.io/v1alpha5 kind: ClusterConfig metadata: name: my-cluster region: ap-northeast-1 fargateProfiles: - name: fp-default selectors: # Namespace 選擇器 - namespace: staging labels: compute-type: fargate - namespace: dev # 特定 workload 選擇器 - namespace: production labels: workload-type: stateless eksctl create fargateprofile -f fargate-profile.yaml 將 Pod 部署到 Fargate：\n# deployment-fargate.yaml apiVersion: apps/v1 kind: Deployment metadata: name: my-app namespace: staging # 匹配 Fargate Profile 的 namespace spec: replicas: 3 selector: matchLabels: app: my-app template: metadata: labels: app: my-app compute-type: fargate # 匹配 Fargate Profile 的 label spec: containers: - name: app image: my-app:latest resources: requests: cpu: \u0026#34;256m\u0026#34; memory: \u0026#34;512Mi\u0026#34; limits: cpu: \u0026#34;512m\u0026#34; memory: \u0026#34;1Gi\u0026#34; Fargate 優缺點分析：\n優點 缺點 ✅ 無需管理 Node ❌ 啟動時間較長（30-60 秒） ✅ 按實際使用付費 ❌ 不支援 DaemonSet ✅ 自動擴展 ❌ 不支援 GPU ✅ 無 Pod 數量限制 ❌ 某些 CSI Driver 不支援 ✅ 安全隔離（每 Pod 獨立 VM） ❌ 無法 SSH 進入 Node ✅ 自動套用安全更新 ❌ 成本可能較高 快速解法：清理不需要的 Pod 如果你只是臨時需要空間，最快的方法是找出並刪除不必要的 Pod：\n# 查看所有 Pod 的資源使用狀況（按 memory 排序） kubectl top pods -A --sort-by=memory # 查看所有 Pod（按啟動時間排序） kubectl get pods -A -o wide --sort-by=\u0026#39;.status.startTime\u0026#39; # 找出長時間運行的 Pod kubectl get pods -A -o custom-columns=\\ NAMESPACE:.metadata.namespace,\\ NAME:.metadata.name,\\ STATUS:.status.phase,\\ AGE:.metadata.creationTimestamp \\ --sort-by=\u0026#39;.metadata.creationTimestamp\u0026#39; # 刪除特定 Pod（如果有 Deployment，會自動重建） kubectl delete pod \u0026lt;pod-name\u0026gt; -n \u0026lt;namespace\u0026gt; # 完全移除一個服務 kubectl delete deployment \u0026lt;deployment-name\u0026gt; -n \u0026lt;namespace\u0026gt; # 檢查是否有 Completed 或 Failed 的 Pod 可以清理 kubectl get pods -A | grep -E \u0026#39;Completed|Error|Failed\u0026#39; kubectl delete pods -A --field-selector=status.phase==Succeeded kubectl delete pods -A --field-selector=status.phase==Failed 我的情況： 刪除了一個用於測試的 debug Pod，Strapi 就成功啟動了。\n（經過三小時的 debug 和兩杯咖啡的犧牲，問題終於解決。）\n預防措施：監控 Pod 容量 建立 CloudWatch 告警 # 使用 AWS CLI 建立告警 aws cloudwatch put-metric-alarm \\ --alarm-name \u0026#34;EKS-Pod-Capacity-Warning\u0026#34; \\ --alarm-description \u0026#34;Pod capacity usage exceeds 80%\u0026#34; \\ --metric-name \u0026#34;pod_count\u0026#34; \\ --namespace \u0026#34;ContainerInsights\u0026#34; \\ --dimensions Name=ClusterName,Value=my-cluster \\ --statistic Average \\ --period 300 \\ --threshold 80 \\ --comparison-operator GreaterThanThreshold \\ --evaluation-periods 2 \\ --alarm-actions arn:aws:sns:ap-northeast-1:123456789012:alerts 使用 Prometheus 監控 # prometheus-rules.yaml apiVersion: monitoring.coreos.com/v1 kind: PrometheusRule metadata: name: pod-capacity-alerts namespace: monitoring spec: groups: - name: pod-capacity rules: - alert: HighPodUtilization expr: | sum(kube_pod_status_phase{phase=\u0026#34;Running\u0026#34;}) by (node) / sum(kube_node_status_allocatable{resource=\u0026#34;pods\u0026#34;}) by (node) \u0026gt; 0.8 for: 5m labels: severity: warning annotations: summary: \u0026#34;Node {{ $labels.node }} Pod 使用率超過 80%\u0026#34; description: \u0026#34;Node {{ $labels.node }} 的 Pod 使用率已達 {{ $value | humanizePercentage }}，請考慮擴展。\u0026#34; - alert: CriticalPodUtilization expr: | sum(kube_pod_status_phase{phase=\u0026#34;Running\u0026#34;}) by (node) / sum(kube_node_status_allocatable{resource=\u0026#34;pods\u0026#34;}) by (node) \u0026gt; 0.95 for: 2m labels: severity: critical annotations: summary: \u0026#34;Node {{ $labels.node }} Pod 容量即將耗盡！\u0026#34; description: \u0026#34;Node {{ $labels.node }} 的 Pod 使用率已達 {{ $value | humanizePercentage }}，可能導致新 Pod 無法排程。\u0026#34; 定期檢查腳本（加入 cron job） #!/bin/bash # /usr/local/bin/check-pod-capacity.sh LOG_FILE=\u0026#34;/var/log/pod-capacity-check.log\u0026#34; SLACK_WEBHOOK=\u0026#34;https://hooks.slack.com/services/xxx/yyy/zzz\u0026#34; echo \u0026#34;$(date): Starting pod capacity check\u0026#34; \u0026gt;\u0026gt; $LOG_FILE WARNING_NODES=\u0026#34;\u0026#34; for node in $(kubectl get nodes -o name); do node_name=$(echo $node | cut -d\u0026#39;/\u0026#39; -f2) max_pods=$(kubectl get $node -o jsonpath=\u0026#39;{.status.allocatable.pods}\u0026#39;) current_pods=$(kubectl get pods -A --field-selector spec.nodeName=$node_name --no-headers | wc -l) if [ \u0026#34;$max_pods\u0026#34; -gt 0 ]; then usage=$((current_pods * 100 / max_pods)) if [ $usage -ge 80 ]; then WARNING_NODES=\u0026#34;$WARNING_NODES\\n- $node_name: ${usage}% ($current_pods/$max_pods)\u0026#34; fi fi done if [ -n \u0026#34;$WARNING_NODES\u0026#34; ]; then # 發送 Slack 通知 curl -X POST -H \u0026#39;Content-type: application/json\u0026#39; \\ --data \u0026#34;{\\\u0026#34;text\\\u0026#34;:\\\u0026#34;⚠️ EKS Pod Capacity Warning\\n\\nThe following nodes have high pod utilization:$WARNING_NODES\\\u0026#34;}\u0026#34; \\ $SLACK_WEBHOOK echo \u0026#34;$(date): Warning sent for nodes:$WARNING_NODES\u0026#34; \u0026gt;\u0026gt; $LOG_FILE fi echo \u0026#34;$(date): Check completed\u0026#34; \u0026gt;\u0026gt; $LOG_FILE # 加入 crontab，每 15 分鐘檢查一次 */15 * * * * /usr/local/bin/check-pod-capacity.sh 架構規劃建議 經過這次踩坑，我總結了幾個 EKS 架構規劃的重點：\n1. 依環境分離 Node Group 2. 選擇適當的 Instance 類型 用途 建議 Instance 原因 System Pods t3.small / t3.medium 系統元件數量固定，不需太大 Production m5.large / m5.xlarge 平衡的 CPU/Memory，足夠的 Pod 容量 CPU 密集 c5.large / c5.xlarge 高 CPU 配置 Memory 密集 r5.large / r5.xlarge 高 Memory 配置 開發測試 t3.medium 成本較低，足夠測試用途 3. 規劃足夠的 VPC CIDR 如果計劃使用 Prefix Delegation，確保 Subnet 有足夠的 IP 空間：\n建議規劃： ├─ VPC CIDR: /16（65,536 個 IP） ├─ 每個 AZ 的 Subnet: /19（8,192 個 IP） └─ 預留給 Pod 的空間: 至少 /20 per AZ 結語：EKS Pod 限制的真相 這次經驗讓我深刻體會到，EKS 的「Pod 數限制」是一個經常被誤解的概念：\n📌 記住：EKS 上 Pod 數上限 ≠ CPU/Memory 限制，而是 ENI + IP 的網路限制！\n當你看到「Too many pods」錯誤時，不要急著加 CPU 或 Memory，而是要：\n檢查 Instance 類型的 ENI/IP 限制 評估是否需要升級 Instance 或增加 Node 考慮啟用 Prefix Delegation 或使用 Fargate 建立監控機制，提早發現問題 希望這篇文章能幫你省下我花掉的那三小時。至於那兩杯咖啡的錢，就當作是我請你的。☕\n參考資源 AWS EKS Pod 網路官方文件 Amazon VPC CNI Plugin GitHub 每個 EC2 Instance 類型的 ENI 限制 Kubernetes Cluster Autoscaler on AWS AWS Fargate on EKS VPC CNI Prefix Delegation 官方部落格 EKS Best Practices Guide ","permalink":"https://peter-notes.com/posts/eks-pod-pending-too-many-pods/","summary":"\u003ch2 id=\"前言一個讓人懷疑人生的-pending-狀態\"\u003e前言：一個讓人懷疑人生的 Pending 狀態\u003c/h2\u003e\n\u003cp\u003e最近部署 Strapi CMS 到 AWS EKS 時，遇到一個詭異的情況：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e$ kubectl get pods -n default\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eNAME                                    READY   STATUS    RESTARTS   AGE\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003emycompany-strapi-prod-695854fbd4-dzw66  0/1     Pending   \u003cspan class=\"m\"\u003e0\u003c/span\u003e          3h42m\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e一個 Pod 卡在 Pending 狀態超過三小時，CPU 和 Memory 明明還很充足，但就是起不來。\u003c/p\u003e\n\u003cp\u003e如果你曾經盯著 \u003ccode\u003ekubectl get pods\u003c/code\u003e 看著那個永遠不會變成 Running 的 Pending 狀態，同時懷疑是不是 Kubernetes 在跟你開玩笑——恭喜你，你不孤單。\u003c/p\u003e\n\u003cp\u003e在嘗試了 Google 前五個搜尋結果、檢查了三次 YAML 設定、並認真考慮是否該轉行當咖啡師之後，我終於找到了問題的根源\u0026hellip;\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e⚠️ 劇透警告：問題的根源不是 CPU、不是 Memory，而是一個你可能從沒注意過的限制——\u003cstrong\u003e網卡（ENI）和 IP 數量\u003c/strong\u003e。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"問題診斷一步步找出真兇\"\u003e問題診斷：一步步找出真兇\u003c/h2\u003e\n\u003ch3 id=\"step-1查看-pod-事件\"\u003eStep 1：查看 Pod 事件\u003c/h3\u003e\n\u003cp\u003e遇到 Pending 狀態，第一步當然是看看 Kubernetes 到底在抱怨什麼：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e$ kubectl describe pod mycompany-strapi-prod-695854fbd4-dzw66 -n default\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e輸出內容很長，但最重要的是 Events 區塊：\u003c/p\u003e","title":"EKS Pod 卡在 Pending？從 Too Many Pods 到 ENI / CNI 限制全面解析"},{"content":"前言:從本地開發到雲端生產環境 本文將深入解析一個全端專案在 AWS 上的完整基礎設施架構,展示如何透過 Kubernetes (EKS) 實現高可用性、可擴展性和成本效益的生產環境。這個平台提供線上課程、預約服務、會員管理和金流整合等功能。\n本文涵蓋內容:\n完整的 AWS EKS 叢集架構 Strapi CMS 和 Vue.js 前端的容器化部署 Jenkins CI/CD 自動化部署流程 Ingress NGINX 負載均衡和 SSL 憑證管理 與 AWS RDS、S3、ECR 的整合 第三方服務整合 (Firebase FCM、台灣金流) 監控與日誌管理 架構概覽 整體架構圖 核心技術棧 基礎設施層:\nAWS EKS 1.32.9 (Kubernetes 託管服務) AWS EC2 (ARM64 架構 - t4g.medium) AWS RDS PostgreSQL (託管資料庫) AWS S3 (物件儲存) AWS ECR (容器映像倉儲) AWS ELB (負載均衡器) CI/CD 層:\nJenkins (Mac mini 本地部署) Docker (容器建置) kubectl (Kubernetes 部署工具) 應用層:\nStrapi v5.15.1 (Headless CMS) Vue.js 3 (前端 SPA) NGINX Ingress Controller (反向代理) Cert-Manager (SSL 憑證自動化) 監控與日誌:\nAmazon CloudWatch Fluent Bit (日誌收集) Metrics Server (資源監控) EKS 叢集架構 叢集基本資訊 Cluster Name: mycom-taipei-cluster Region: ap-southeast-1 (Asia Pacific - Hong Kong) Kubernetes Version: v1.32.9-eks-113cf36 Cluster Endpoint: 151FF270201594366F4072D7F2D31E98.sk1.ap-southeast-1.eks.amazonaws.com Account ID: AWS_Account_ID 設計考量:\n選擇 ap-southeast-1（新加坡）區域，作為亞太主要節點之一，提供穩定服務，對台灣用戶延遲可接受 使用最新的 EKS 版本確保安全性和功能更新 單一叢集設計,透過 namespace 隔離不同功能 節點配置 # 節點詳細資訊 NAME: ip-192-168-250-55.ap-southeast-1.compute.internal INSTANCE TYPE: t4g.medium (ARM64) CPU: 2 核心 RAM: 4GB OS: Amazon Linux 2 Kernel: 5.10.244-240.965.amzn2.aarch64 Container Runtime: containerd 1.7.27 資源分配:\n資源 總容量 可分配 已使用 使用率 CPU 2000m 1930m 51m 2% Memory 3.9Gi 3.3Gi 1.5Gi 47% Pods 17 17 14 82% 節點選型分析:\n✅ 優勢:\nARM64 架構 (Graviton) 提供更好的性價比 t4g.medium 適合中小型應用,成本效益高 記憶體使用率 47% 留有充足緩衝空間 ⚠️ 注意事項:\nPod 數量接近上限 (14/17),需要監控 單節點設計沒有高可用性,建議未來擴展為 2-3 節點 CPU 使用率極低 (2%),可能存在資源浪費 命名空間架構與資源分布 命名空間設計 kubectl get namespaces Namespace 用途 Pod 數量 關鍵服務 default 應用程式主要部署 3 Strapi, Vue.js ingress-nginx 流量入口管理 1 Ingress Controller cert-manager SSL 憑證管理 3 Cert Manager kube-system Kubernetes 核心服務 4 CoreDNS, Metrics amazon-cloudwatch 日誌和監控 1 Fluent Bit kube-node-lease 節點心跳管理 0 (系統用) kube-public 公開資訊 0 (系統用) 設計模式:\n這是一個典型的單體叢集多命名空間設計,透過 namespace 實現邏輯隔離:\n應用層 (default) 流量層 (ingress-nginx) 安全層 (cert-manager) 系統層 (kube-system) 監控層 (amazon-cloudwatch) Jenkins CI/CD 自動化部署 Jenkins 環境配置 部署位置: Mac mini (本地機房) Jenkins 版本: LTS 用途: 自動化建置和部署應用程式 安裝的外掛程式:\nDocker Pipeline Kubernetes CLI AWS Steps (ECR 推送) Git Pipeline CI/CD 流程架構 Jenkins Pipeline 建置流程說明 Strapi 後端部署流程：\nCheckout - 從 Git Repository 拉取最新程式碼 Get Version - 從 Git tag 取得版本號（格式：prod-1.117） Build Docker Image - 建置 Docker 映像檔並標記版本 Push to ECR - 登入 AWS ECR 並推送映像檔 Deploy to EKS - 使用 kubectl 更新 Kubernetes Deployment Verify Deployment - 檢查 Pod 狀態和健康檢查端點 Post Actions - 成功時通知團隊，失敗時自動回滾 Vue.js 前端部署流程：\nCheckout - 從 Git Repository 拉取最新程式碼 Get Version - 從 Git tag 取得版本號 Build Application - 執行 npm install 和 npm run build_prod Build Docker Image - 將打包後的靜態檔案建置成 Docker 映像檔 Push to ECR - 推送映像檔到 AWS ECR Deploy to EKS - 更新 Kubernetes Deployment Post Actions - 成功/失敗通知與自動回滾機制 環境變數配置：\n變數名稱 說明 AWS_REGION AWS 區域 (ap-southeast-1) ECR_REGISTRY ECR 倉儲位址 IMAGE_NAME Docker 映像檔名稱 K8S_NAMESPACE Kubernetes 命名空間 K8S_DEPLOYMENT Deployment 名稱 版本管理策略 Git Tag 驅動部署:\n# 開發人員建立新版本 git tag -a prod-1.117 -m \u0026#34;Release version 1.117\u0026#34; git push origin prod-1.117 # Jenkins 自動觸發 Pipeline # Pipeline 從 tag 擷取版本號 (1.117) # 建置映像檔: mycom-strapi:1.117 # 部署到 EKS 版本號規則:\n格式: prod-主版本.次版本 範例: prod-1.117, prod-1.118 每次部署遞增次版本號 映像檔標籤策略:\n# ECR 映像檔標籤 AWS_Account_ID.dkr.ecr.ap-southeast-1.amazonaws.com/mycom-strapi:1.117 # 版本標籤 AWS_Account_ID.dkr.ecr.ap-southeast-1.amazonaws.com/mycom-strapi:latest # 最新標籤 Jenkins 與 AWS 整合 IAM 權限設定:\n{ \u0026#34;Version\u0026#34;: \u0026#34;2012-10-17\u0026#34;, \u0026#34;Statement\u0026#34;: [ { \u0026#34;Effect\u0026#34;: \u0026#34;Allow\u0026#34;, \u0026#34;Action\u0026#34;: [ \u0026#34;ecr:GetAuthorizationToken\u0026#34;, \u0026#34;ecr:BatchCheckLayerAvailability\u0026#34;, \u0026#34;ecr:GetDownloadUrlForLayer\u0026#34;, \u0026#34;ecr:PutImage\u0026#34;, \u0026#34;ecr:InitiateLayerUpload\u0026#34;, \u0026#34;ecr:UploadLayerPart\u0026#34;, \u0026#34;ecr:CompleteLayerUpload\u0026#34; ], \u0026#34;Resource\u0026#34;: \u0026#34;*\u0026#34; }, { \u0026#34;Effect\u0026#34;: \u0026#34;Allow\u0026#34;, \u0026#34;Action\u0026#34;: [ \u0026#34;eks:DescribeCluster\u0026#34; ], \u0026#34;Resource\u0026#34;: \u0026#34;arn:aws:eks:ap-southeast-1:AWS_Account_ID:cluster/mycom-taipei-cluster\u0026#34; } ] } Jenkins Credentials 設定:\nAWS Credentials: - Type: AWS Credentials - ID: aws-credentials - Access Key ID: (存放在 Jenkins) - Secret Access Key: (存放在 Jenkins) Kubeconfig: - Type: Secret file - ID: kubeconfig - File: ~/.kube/config (EKS 叢集存取設定) 應用服務部署 Strapi CMS 後端 (mycom-strapi-prod) apiVersion: apps/v1 kind: Deployment metadata: name: mycom-strapi-prod namespace: default spec: replicas: 1 selector: matchLabels: app: mycom-strapi-prod template: metadata: labels: app: mycom-strapi-prod spec: containers: - name: mycom-strapi-prod image: AWS_Account_ID.dkr.ecr.ap-southeast-1.amazonaws.com/mycom-strapi:1.117 ports: - containerPort: 1337 resources: requests: cpu: 300m memory: 512Mi limits: cpu: 1500m memory: 2Gi livenessProbe: httpGet: path: /_health port: 1337 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /_health port: 1337 initialDelaySeconds: 30 periodSeconds: 10 環境變數配置 (部分):\nenv: # Strapi 核心設定 - name: APP_KEYS valueFrom: secretKeyRef: name: mycom-strapi-prod-secrets key: app-keys # 資料庫連線 - name: DATABASE_CLIENT value: \u0026#34;postgres\u0026#34; - name: DATABASE_HOST value: \u0026#34;postgres-strapi-sg.Resource_ID.ap-southeast-1.rds.amazonaws.com\u0026#34; - name: DATABASE_PORT value: \u0026#34;5432\u0026#34; - name: DATABASE_NAME value: \u0026#34;strapi_DB\u0026#34; # AWS S3 設定 - name: AWS_REGION value: \u0026#34;ap-southeast-1\u0026#34; - name: AWS_BUCKET value: \u0026#34;mycom-strapi-prod-s3-taipei\u0026#34; # 金流整合 - name: NEWEBPAY_MERCHANT_ID value: \u0026#34;MS123456789\u0026#34; - name: NEWEBPAY_API_URL value: \u0026#34;https://core.newebpay.com/MPG/mpg_gateway\u0026#34; # Firebase 推播通知 - name: FIREBASE_PROJECT_ID value: \u0026#34;ella\u0026#34; - name: GOOGLE_APPLICATION_CREDENTIALS value: \u0026#34;/etc/gcp/firebase/firebase-adminsdk.json\u0026#34; volumeMounts: - name: firebase-secret mountPath: /etc/gcp/firebase readOnly: true volumes: - name: firebase-secret secret: secretName: firebase-mycom-admin-secret 關鍵特性分析:\n健康檢查機制\nLiveness Probe: 確保容器持續運行 Readiness Probe: 確保服務就緒後才接收流量 30 秒啟動延遲,避免頻繁重啟 資源配置策略\nRequest: 300m CPU / 512Mi RAM (最低保證) Limit: 1.5 CPU / 2Gi RAM (最高上限) 實際使用: ~1m CPU / 281Mi RAM 觀察: CPU 嚴重低估 (1m vs 300m request),可能造成資源浪費 安全性設計\n所有敏感資訊 (密碼、API 金鑰) 存放在 Kubernetes Secret Firebase 憑證透過 Volume Mount 注入 資料庫連線啟用 SSL (DATABASE_SSL: true) 版本管理\n映像檔版本: v1.117 (已執行 112 次版本更新) 使用 AWS ECR 私有倉儲 RollingUpdate 策略確保零停機部署 Vue.js 前端 (mycom-web) apiVersion: apps/v1 kind: Deployment metadata: name: mycom-web namespace: default labels: app: mycom-web env: prod spec: replicas: 1 selector: matchLabels: app: mycom-web env: prod template: spec: containers: - name: mycom-web-prod image: AWS_Account_ID.dkr.ecr.ap-southeast-1.amazonaws.com/mycom-web:1.61 ports: - containerPort: 80 resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512Mi livenessProbe: httpGet: path: /_health port: 80 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /_health port: 80 initialDelaySeconds: 30 periodSeconds: 10 架構特點:\n靜態資產託管: Vue.js SPA 打包後透過 NGINX 容器提供服務 輕量化設計: 相比 Strapi,資源需求更低 (100m CPU / 128Mi RAM) 環境標籤: 使用 env: prod 標籤區分環境 實際使用: ~1m CPU / 26Mi RAM (資源利用率極低) 流量入口與負載均衡 Ingress NGINX Controller # Ingress Controller Service kind: Service metadata: name: ingress-nginx-controller namespace: ingress-nginx spec: type: LoadBalancer externalTrafficPolicy: Local ports: - name: http port: 80 targetPort: http nodePort: 30869 - name: https port: 443 targetPort: https nodePort: 31328 selector: app.kubernetes.io/component: controller app.kubernetes.io/instance: ingress-nginx AWS ELB 資訊:\nExternal IP: af0e5d61d38d3425399c76bfcd0e9dcd-703f7869948813ee.elb.ap-southeast-1.amazonaws.com Type: Classic Load Balancer (CLB) Ports: 80 (HTTP) → 443 (HTTPS 重定向) Ingress 路由規則 CMS 管理後台路由 apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: mycom-strapi-ingress namespace: default annotations: # SSL 強制重定向 nginx.ingress.kubernetes.io/force-ssl-redirect: \u0026#34;true\u0026#34; # 大檔案上傳支援 (4GB) nginx.ingress.kubernetes.io/proxy-body-size: \u0026#34;4096m\u0026#34; nginx.ingress.kubernetes.io/client-max-body-size: \u0026#34;4096m\u0026#34; # 長時間請求支援 (1小時) nginx.ingress.kubernetes.io/proxy-read-timeout: \u0026#34;3600\u0026#34; nginx.ingress.kubernetes.io/proxy-send-timeout: \u0026#34;3600\u0026#34; nginx.ingress.kubernetes.io/client-body-timeout: \u0026#34;3600\u0026#34; nginx.ingress.kubernetes.io/client-header-timeout: \u0026#34;3600\u0026#34; # 代理緩衝設定 nginx.ingress.kubernetes.io/proxy-buffering: \u0026#34;off\u0026#34; nginx.ingress.kubernetes.io/proxy-request-buffering: \u0026#34;off\u0026#34; nginx.ingress.kubernetes.io/proxy-buffer-size: \u0026#34;64k\u0026#34; nginx.ingress.kubernetes.io/proxy-buffers-number: \u0026#34;16\u0026#34; # 轉發標頭 nginx.ingress.kubernetes.io/use-forwarded-headers: \u0026#34;true\u0026#34; nginx.ingress.kubernetes.io/rewrite-target: \u0026#34;/\u0026#34; spec: ingressClassName: nginx tls: - hosts: - cms.example.com secretName: cms-tls-secret rules: - host: cms.example.com http: paths: - path: / pathType: Prefix backend: service: name: mycom-strapi-service-prod port: number: 80 設定解析:\n這些 annotation 針對 Strapi CMS 的特殊需求進行優化:\n大檔案上傳 (4GB)\n支援影片、高解析度圖片上傳 proxy-body-size: 4096m 設定最大請求大小 長時間連線 (1小時)\n影片轉檔、大量資料匯入等長時間操作 所有 timeout 設定為 3600 秒 (1小時) 代理緩衝關閉\nproxy-buffering: off 避免記憶體溢位 大檔案串流上傳不透過記憶體緩衝 SSL 強制重定向\n所有 HTTP 請求自動重定向到 HTTPS 確保管理後台連線安全 前端網站路由 apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: mycom-web-ingress namespace: default spec: ingressClassName: nginx tls: - hosts: - www.example.com secretName: www-example-com-cert rules: - host: www.example.com http: paths: - path: / pathType: Prefix backend: service: name: mycom-web-service-prod port: number: 80 設定特點:\n使用預設的 NGINX 設定 (無特殊 annotation) 靜態資產服務不需要大檔案或長時間連線支援 同樣啟用 SSL/TLS 加密 SSL/TLS 憑證管理 Cert-Manager 架構 Cert-Manager 是一個 Kubernetes 原生的憑證管理解決方案,自動處理 SSL/TLS 憑證的簽發、更新和輪換。\n# Cert-Manager 元件 kubectl get pods -n cert-manager 元件 用途 Replicas cert-manager 核心控制器,處理 Certificate 資源 1 cert-manager-cainjector 注入 CA bundle 到 Webhook/APIService 1 cert-manager-webhook 驗證和變更 Cert-Manager 資源 1 TWCA OV SSL 憑證整合 (規劃中) 當前狀態:\n使用 Let\u0026rsquo;s Encrypt 自動簽發憑證 已購買 TWCA OV SSL 憑證,尚未部署 TWCA OV SSL 部署步驟:\n取得憑證檔案 # TWCA 提供的檔案 certificate.crt # 網域憑證 ca_bundle.crt # 中繼憑證 private.key # 私鑰 (由申請單位持有) 建立 Kubernetes Secret # 合併完整憑證鏈 cat certificate.crt ca_bundle.crt \u0026gt; fullchain.crt # 建立 TLS Secret kubectl create secret tls cms-tls-secret \\ --cert=fullchain.crt \\ --key=private.key \\ -n default kubectl create secret tls www-example-com-cert \\ --cert=fullchain.crt \\ --key=private.key \\ -n default 更新 Ingress 設定 apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: mycom-strapi-ingress annotations: # 移除 cert-manager 自動簽發 # cert-manager.io/cluster-issuer: \u0026#34;letsencrypt-prod\u0026#34; spec: tls: - hosts: - cms.example.com secretName: cms-tls-secret # 使用手動建立的 Secret 驗證憑證 # 檢查憑證資訊 openssl x509 -in certificate.crt -text -noout # 測試 HTTPS 連線 curl -vI https://cms.example.com TWCA OV SSL vs Let\u0026rsquo;s Encrypt 比較:\n特性 TWCA OV SSL Let\u0026rsquo;s Encrypt 憑證類型 OV (組織驗證) DV (網域驗證) 信任度 更高 (顯示組織名稱) 一般 有效期 1-2 年 90 天 自動更新 需手動更新 自動更新 成本 付費 免費 適用場景 企業官網、電商平台 一般網站 為什麼選擇 TWCA OV SSL:\n✅ 台灣本地憑證機構,符合法規要求 ✅ OV 憑證提供更高的信任度 ✅ 適合醫療健康產業的資安標準 ✅ 瀏覽器顯示組織名稱,提升品牌形象 憑證自動化流程 (Let\u0026rsquo;s Encrypt) 資料庫與儲存架構 AWS RDS PostgreSQL Host: postgres-strapi-sg.Resource_ID.ap-southeast-1.rds.amazonaws.com Port: 5432 Database: strapi_DB Engine: PostgreSQL Region: ap-southeast-1 SSL: Enabled (SSL_REJECT_UNAUTHORIZED: false) 連線配置 (Strapi):\n// config/database.js module.exports = ({ env }) =\u0026gt; ({ connection: { client: \u0026#39;postgres\u0026#39;, connection: { host: env(\u0026#39;DATABASE_HOST\u0026#39;), port: env.int(\u0026#39;DATABASE_PORT\u0026#39;, 5432), database: env(\u0026#39;DATABASE_NAME\u0026#39;), user: env(\u0026#39;DATABASE_USERNAME\u0026#39;), password: env(\u0026#39;DATABASE_PASSWORD\u0026#39;), ssl: env.bool(\u0026#39;DATABASE_SSL\u0026#39;, false) \u0026amp;\u0026amp; { rejectUnauthorized: env.bool(\u0026#39;DATABASE_SSL_REJECT_UNAUTHORIZED\u0026#39;, false) } }, pool: { min: 2, max: 10 } } }) 設計考量:\n✅ 優勢:\n託管服務,無需維護資料庫伺服器 自動備份和時間點恢復 (PITR) 與 EKS 叢集位於同一區域 (ap-southeast-1),低延遲 SSL 加密連線,資料傳輸安全 ⚠️ 潛在改進:\n使用 RDS Proxy 可以改善連線池管理 考慮啟用 Multi-AZ 部署提升可用性 監控慢查詢並建立適當索引 AWS S3 儲存 Bucket: mycom-strapi-prod-s3-taipei Region: ap-southeast-1 Purpose: 媒體檔案儲存 (圖片、影片、文件) Strapi S3 整合配置:\n// config/plugins.js module.exports = ({ env }) =\u0026gt; ({ upload: { config: { provider: \u0026#39;aws-s3\u0026#39;, providerOptions: { accessKeyId: env(\u0026#39;AWS_ACCESS_KEY_ID\u0026#39;), secretAccessKey: env(\u0026#39;AWS_ACCESS_SECRET\u0026#39;), region: env(\u0026#39;AWS_REGION\u0026#39;), params: { Bucket: env(\u0026#39;AWS_BUCKET\u0026#39;), }, }, }, }, }) S3 使用場景:\n使用者上傳內容\n會員大頭照 課程影片和教材 服務相關圖片 CMS 管理內容\n文章封面圖 Banner 圖片 行銷素材 系統生成內容\n影片縮圖 (透過 FFmpeg 處理) 多尺寸圖片 (responsive images) 第三方服務整合 Firebase Cloud Messaging (FCM) 用途: 推播通知服務\n環境變數: FIREBASE_PROJECT_ID: ella GOOGLE_APPLICATION_CREDENTIALS: /etc/gcp/firebase/firebase-adminsdk.json Volume Mount: Secret: firebase-mycom-admin-secret Mount Path: /etc/gcp/firebase Read Only: true 通知場景:\n預約提醒 (預約前 1 天、1 小時) 課程上架通知 訂單狀態更新 系統重要公告 安全性設計:\nFirebase Admin SDK 憑證儲存在 Kubernetes Secret 透過 Volume Mount 注入容器 Read-only 掛載,防止憑證被修改 Newebpay 金流整合 用途: 台灣信用卡、ATM、超商支付\n環境變數: NEWEBPAY_MERCHANT_ID: MS1234567890 NEWEBPAY_HASH_KEY: (存放在 Secret) NEWEBPAY_HASH_IV: (存放在 Secret) NEWEBPAY_API_URL: https://core.newebpay.com/MPG/mpg_gateway 金流處理流程:\nEZPay 電子發票整合 用途: 開立和管理電子發票\n環境變數: EZPAY_MERCHANT_ID: 123456789 EZPAY_HASH_KEY: (存放在 Secret) EZPAY_HASH_IV: (存放在 Secret) EZPAY_API_URL: https://inv.ezpay.com.tw/Api/invoice_issue 發票開立流程:\n訂單付款成功後自動觸發 Strapi 呼叫 EZPay API 開立發票 回傳發票號碼和連結 透過 Email 和推播通知使用者 SendGrid 郵件服務 用途: 交易郵件和行銷郵件\n環境變數: SENDGRID_API_KEY: (存放在 Secret) SENDGRID_DEFAULT_FROM: no-reply@example.com SENDGRID_DEFAULT_REPLY_TO: info@example.com # 備用 SMTP (Gmail) SMTP_HOST: smtp.gmail.com SMTP_PORT: 465 SMTP_USERNAME: info@example.com SMTP_PASSWORD: (存放在 Secret) 郵件類型:\n會員註冊驗證信 密碼重設連結 訂單確認信 發票通知信 預約提醒信 行銷活動 EDM 監控與日誌管理 Amazon CloudWatch 整合 Namespace: amazon-cloudwatch DaemonSet: fluent-bit (在每個節點上運行) Fluent Bit 配置:\napiVersion: v1 kind: ConfigMap metadata: name: fluent-bit-config namespace: amazon-cloudwatch data: fluent-bit.conf: | [SERVICE] Flush 5 Daemon off Log_Level info [INPUT] Name tail Path /var/log/containers/*.log Parser docker Tag kube.* Refresh_Interval 5 [FILTER] Name kubernetes Match kube.* Kube_URL https://kubernetes.default.svc:443 Merge_Log On K8S-Logging.Parser On [OUTPUT] Name cloudwatch_logs Match * region ap-southeast-1 log_group_name /aws/eks/mycom-taipei-cluster/containers auto_create_group true 日誌收集範圍:\n所有 Pod 的 stdout/stderr 輸出 Kubernetes 系統元件日誌 應用程式錯誤和警告訊息 Metrics Server kubectl get deployment metrics-server -n kube-system 用途:\n提供 kubectl top 命令的資料來源 HPA (Horizontal Pod Autoscaler) 的指標來源 監控 CPU 和記憶體使用率 實際使用範例:\n# 檢視節點資源使用情況 kubectl top nodes NAME CPU(cores) CPU% MEMORY(bytes) MEMORY% ip-192-168-1-55.ap-southeast-1.compute.internal 51m 2% 1572Mi 47% # 檢視 Pod 資源使用情況 kubectl top pods -n default NAME CPU(cores) MEMORY(bytes) mycom-strapi-prod-1234567890-lmmm4 1m 281Mi mycom-web-1234567890-x6fmx 1m 26Mi psql-client 0m 15Mi 成本分析與優化 當前資源使用總覽 資源類型 規格 數量 月成本估算 (USD) EKS Cluster 控制平面 1 $73 EC2 (t4g.medium) 2 vCPU / 4GB RAM 1 $24 RDS PostgreSQL db.t3.micro 1 $15-30 S3 標準儲存 ~100GB $2-5 ELB (CLB) 負載均衡器 1 $18 ECR 映像檔儲存 ~10GB $1 Jenkins (Mac mini) 本地部署 1 $0 (已有硬體) 總計 $133-151 資源利用率分析 CPU 使用率:\n節點總容量: 2000m 已使用: 51m (2.5%) 浪費: 1949m (97.5%) ❌ 記憶體使用率:\n節點總容量: 3.9Gi 已使用: 1.5Gi (38%) 浪費: 2.4Gi (62%) ⚠️ 優化建議 1. 降低 Pod 資源 Request 目前配置 vs 實際使用:\nPod Request CPU 實際使用 浪費 建議調整 Strapi 300m 1m 299m 50m Vue.js 100m 1m 99m 20m # 建議的 Strapi 資源配置 resources: requests: cpu: 50m # 從 300m 降至 50m memory: 256Mi # 從 512Mi 降至 256Mi limits: cpu: 1000m # 從 1500m 降至 1000m memory: 1Gi # 從 2Gi 降至 1Gi 預期效益:\n節省 ~350m CPU request 節省 ~256Mi 記憶體 request 可以在同一節點上運行更多 Pod 結語 架構優勢總結 ✅ 已實現的優勢:\n容器化部署: Docker + Kubernetes 確保環境一致性 自動化 CI/CD: Jenkins 自動建置和部署 託管服務: EKS、RDS 降低營運負擔 日誌集中: CloudWatch 統一監控 零停機部署: RollingUpdate 策略 ✅ 架構亮點:\nARM64 節點降低成本 20-30% Ingress 精細化設定支援大檔案和長時間請求 完整的第三方整合 (FCM、Newebpay、SendGrid) Jenkins 與 AWS ECR/EKS 無縫整合 改進優先級 短期 (1-2 個月):\n⚡ 最高優先: 調整 Pod 資源 request/limit (立即節省成本) ⚡ 最高優先: 增加節點和 Pod 副本數 (提升可用性) 🔧 部署 TWCA OV SSL 憑證 🔧 啟用 RDS 自動備份和快照 中期 (3-6 個月):\n🚀 啟用 RDS Multi-AZ 部署 🚀 整合 Redis 快取 🚀 部署 CloudFront CDN 🛡️ 實施 Pod Security Standards 長期 (6-12 個月):\n🌐 跨區域災難復原 🌐 多叢集架構 (開發/測試/生產) 🤖 完整的 GitOps 流程 (ArgoCD) 🤖 自動化容量規劃和成本優化 參考資源 AWS 官方文件 Amazon EKS 最佳實踐指南 AWS Well-Architected Framework RDS Multi-AZ 部署 AWS ECR 使用者指南 Kubernetes 資源 Kubernetes 生產環境最佳實踐 Ingress NGINX Controller 文件 Cert-Manager 文件 CI/CD 與自動化 Jenkins Pipeline 文件 Docker Build Best Practices GitOps 實踐指南 應用框架 Strapi v5 官方文件 Vue.js 3 效能優化 文章撰寫日期: 2024-06-05 架構掃描日期: 2024-06-05 叢集版本: EKS 1.32.9 應用版本: Strapi v5.15, Vue.js v1.61\n","permalink":"https://peter-notes.com/posts/aws-eks-healthcare-platform-architecture/","summary":"\u003ch2 id=\"前言從本地開發到雲端生產環境\"\u003e前言:從本地開發到雲端生產環境\u003c/h2\u003e\n\u003cp\u003e本文將深入解析一個全端專案在 AWS 上的完整基礎設施架構,展示如何透過 Kubernetes (EKS) 實現高可用性、可擴展性和成本效益的生產環境。這個平台提供線上課程、預約服務、會員管理和金流整合等功能。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e本文涵蓋內容:\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e完整的 AWS EKS 叢集架構\u003c/li\u003e\n\u003cli\u003eStrapi CMS 和 Vue.js 前端的容器化部署\u003c/li\u003e\n\u003cli\u003eJenkins CI/CD 自動化部署流程\u003c/li\u003e\n\u003cli\u003eIngress NGINX 負載均衡和 SSL 憑證管理\u003c/li\u003e\n\u003cli\u003e與 AWS RDS、S3、ECR 的整合\u003c/li\u003e\n\u003cli\u003e第三方服務整合 (Firebase FCM、台灣金流)\u003c/li\u003e\n\u003cli\u003e監控與日誌管理\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"架構概覽\"\u003e架構概覽\u003c/h2\u003e\n\u003ch3 id=\"整體架構圖\"\u003e整體架構圖\u003c/h3\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/2024-06-05-aws-eks-project-a-architecture-diagram-97be013f.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003ch3 id=\"核心技術棧\"\u003e核心技術棧\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e基礎設施層:\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eAWS EKS 1.32.9 (Kubernetes 託管服務)\u003c/li\u003e\n\u003cli\u003eAWS EC2 (ARM64 架構 - t4g.medium)\u003c/li\u003e\n\u003cli\u003eAWS RDS PostgreSQL (託管資料庫)\u003c/li\u003e\n\u003cli\u003eAWS S3 (物件儲存)\u003c/li\u003e\n\u003cli\u003eAWS ECR (容器映像倉儲)\u003c/li\u003e\n\u003cli\u003eAWS ELB (負載均衡器)\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eCI/CD 層:\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eJenkins (Mac mini 本地部署)\u003c/li\u003e\n\u003cli\u003eDocker (容器建置)\u003c/li\u003e\n\u003cli\u003ekubectl (Kubernetes 部署工具)\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e應用層:\u003c/strong\u003e\u003c/p\u003e","title":"全端專案 AWS EKS 雲端架構深度解析"},{"content":"問題背景 最近在開發過程中遇到一個詭異的問題：呼叫某個 API 後，某個常數 name 的值居然是 nil，但從 raw data 看起來明明有值。\n症狀檢查清單：\n✅ Console 印出 raw data 看起來正常 ✅ jsonDecode 解碼成功 ✅ Enum 對應的 JSON key (_Name_Ch) 完全相同 ✅ 瀏覽器中直接訪問 API，name 確實有值 ❌ Swift 中取得的 name 卻是 nil 經過反覆檢查，終於發現問題根源：不可見的 BOM (Byte-Order Mark) 字元。\n什麼是 BOM？ BOM (Byte-Order Mark)，中文稱為位元組順序記號，是一個不可見的 Unicode 字元，用於標示文字檔的編碼位元組順序。\n常見的 BOM 字元：\nUTF-8 BOM: 0xEF 0xBB 0xBF (Unicode: U+FEFF) UTF-16 BE BOM: 0xFE 0xFF UTF-16 LE BOM: 0xFF 0xFE 問題診斷 根據問題分析，name 為 nil 的原因是：\nAPI 回應的 JSON 資料中，_Name_Ch 這個 key 的前面有一個不可見的 BOM 字元，導致 Swift 的 JSON 解碼器無法正確匹配 key 名稱。\n實際的 JSON key 對比：\n// 我們期望的 key \u0026#34;_Name_Ch\u0026#34; // 實際的 key（包含不可見的 BOM） \u0026#34;\\u{feff}_Name_Ch\u0026#34; // \\u{feff} 就是 UTF-8 BOM 由於 key 名稱不匹配，JSONDecoder 找不到對應的欄位，因此 name 變成 nil。\n解決方案 方法 1：清除 BOM 字元（推薦） 在解析 JSON 之前，先移除所有 BOM 字元：\n// Swift 解決方案 func cleanBOM(from jsonString: String) -\u0026gt; String { return jsonString.replacingOccurrences(of: \u0026#34;\\u{feff}\u0026#34;, with: \u0026#34;\u0026#34;) } // 使用方式 let rawJSON = String(data: responseData, encoding: .utf8) ?? \u0026#34;\u0026#34; let cleanedJSON = cleanBOM(from: rawJSON) // 然後再進行 JSON 解碼 if let data = cleanedJSON.data(using: .utf8) { let decoded = try JSONDecoder().decode(YourModel.self, from: data) } 方法 2：建立 String Extension（可重用） 如果需要在多處使用，建議建立 extension：\nextension String { /// 移除字串中的 BOM (Byte-Order Mark) 字元 func removingBOM() -\u0026gt; String { var result = self // 移除 UTF-8 BOM result = result.replacingOccurrences(of: \u0026#34;\\u{feff}\u0026#34;, with: \u0026#34;\u0026#34;) // 移除 UTF-16 BE BOM（較少見） result = result.replacingOccurrences(of: \u0026#34;\\u{fffe}\u0026#34;, with: \u0026#34;\u0026#34;) return result } } // 使用方式 let cleanedJSON = rawJSON.removingBOM() 方法 3：後端修正（根本解決） 最佳做法是請後端團隊修正 API，確保回應的 JSON 不包含 BOM：\n// Node.js Express 範例 app.get(\u0026#39;/api/data\u0026#39;, (req, res) =\u0026gt; { const data = fetchData() // 確保 Content-Type 正確，明確指定編碼 res.setHeader(\u0026#39;Content-Type\u0026#39;, \u0026#39;application/json; charset=utf-8\u0026#39;) // 使用 JSON.stringify 避免 BOM 問題 res.send(JSON.stringify(data)) }) # Python Flask 範例 @app.route(\u0026#39;/api/data\u0026#39;) def get_data(): data = fetch_data() # Flask 的 jsonify 會自動設定正確的編碼 return jsonify(data), 200, {\u0026#39;Content-Type\u0026#39;: \u0026#39;application/json; charset=utf-8\u0026#39;} 為什麼只有特定欄位受影響？ BOM 通常出現在以下情況：\n檔案開頭\nWindows 記事本等編輯器會自動在檔案開頭加入 BOM 如果 JSON 資料來自檔案讀取，可能帶有 BOM 字串拼接\n從不同來源拼接的資料可能意外帶入 BOM 例如：從資料庫讀取 + 手動拼接字串 資料庫匯出\n某些資料庫工具（如 Excel 匯出 CSV）會加入 BOM 如果後端從這些來源讀取資料，可能保留 BOM 檔案編碼轉換\n編碼轉換過程中意外引入 BOM 例如：UTF-16 轉 UTF-8 時沒有正確處理 如果只有 name 欄位受影響，可能是因為：\n該欄位的值來自不同的資料來源 該欄位經過特殊的處理或轉換 資料輸入時使用了包含 BOM 的編輯器 預防措施 開發階段 1. 檢查 API 回應是否有 BOM\n使用 hexdump 或類似工具檢查原始位元組：\n# 使用 hexdump 檢查檔案開頭 hexdump -C response.json | head # 如果看到 \u0026#34;ef bb bf\u0026#34; 開頭，就是 UTF-8 BOM 00000000 ef bb bf 7b 22 6e 61 6d 65 22 3a 22 76 61 6c 75 |...{\u0026#34;name\u0026#34;:\u0026#34;valu| 2. 配置編輯器避免 BOM\n// VS Code 設定 (.vscode/settings.json) { \u0026#34;files.encoding\u0026#34;: \u0026#34;utf8\u0026#34;, // 使用 UTF-8 without BOM \u0026#34;files.autoGuessEncoding\u0026#34;: false } // Sublime Text 設定 Preferences → Settings { \u0026#34;default_encoding\u0026#34;: \u0026#34;UTF-8\u0026#34;, \u0026#34;fallback_encoding\u0026#34;: \u0026#34;UTF-8\u0026#34; } 測試階段 建立自動化測試檢查 BOM：\nimport XCTest class APITests: XCTestCase { func testNoBOMInAPIResponse() async throws { // 呼叫 API let response = try await APIClient.shared.fetchData() // 轉換為字串檢查 let jsonString = String(data: response, encoding: .utf8) // 斷言不包含 BOM XCTAssertFalse( jsonString?.hasPrefix(\u0026#34;\\u{feff}\u0026#34;) ?? false, \u0026#34;API response should not contain BOM character\u0026#34; ) } func testJSONDecodingWithBOM() throws { // 測試解碼器能否處理帶 BOM 的 JSON let bomJSON = \u0026#34;\\u{feff}{\\\u0026#34;name\\\u0026#34;:\\\u0026#34;Test\\\u0026#34;}\u0026#34; let data = bomJSON.data(using: .utf8)! // 這個測試應該會失敗（除非有處理 BOM） XCTAssertThrowsError( try JSONDecoder().decode(TestModel.self, from: data) ) // 清除 BOM 後應該成功 let cleanedData = bomJSON.removingBOM().data(using: .utf8)! XCTAssertNoThrow( try JSONDecoder().decode(TestModel.self, from: cleanedData) ) } } 生產環境監控 在 API Client 中加入 BOM 偵測和警告：\nclass APIClient { func fetchData() async throws -\u0026gt; Data { let (data, response) = try await URLSession.shared.data(from: url) // 檢查是否有 BOM if let jsonString = String(data: data, encoding: .utf8), jsonString.hasPrefix(\u0026#34;\\u{feff}\u0026#34;) { // 記錄警告到分析工具 Analytics.logWarning(\u0026#34;API response contains BOM\u0026#34;, metadata: [ \u0026#34;endpoint\u0026#34;: url.absoluteString ]) // 自動清理 BOM let cleaned = jsonString.removingBOM() return cleaned.data(using: .utf8) ?? data } return data } } 結論 不可見的 BOM 字元是 JSON 解析中一個隱藏但常見的陷阱。當遇到看似正常的 JSON 卻無法正確解析時，記得檢查是否有 BOM 字元的存在。\n關鍵要點：\n✅ BOM 是不可見的 Unicode 字元，用於標示編碼順序 ✅ 會導致 JSON key 名稱匹配失敗 ✅ 可以用字串替換方法移除 ✅ 最好從 API 後端根除問題 ✅ 建立自動化測試和監控機制 最佳實踐：\n開發環境：確保編輯器不會自動加入 BOM API 開發：後端確保輸出不包含 BOM 客戶端：建立防禦性程式碼，自動清理 BOM 測試：加入 BOM 偵測的自動化測試 參考資源 維基百科：位元組順序記號 Swift String Documentation JSONDecoder Best Practices Unicode Standard: Byte Order Mark ","permalink":"https://peter-notes.com/posts/api-related-notes/","summary":"\u003ch2 id=\"問題背景\"\u003e問題背景\u003c/h2\u003e\n\u003cp\u003e最近在開發過程中遇到一個詭異的問題：呼叫某個 API 後，某個常數 \u003ccode\u003ename\u003c/code\u003e 的值居然是 \u003ccode\u003enil\u003c/code\u003e，但從 raw data 看起來明明有值。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e症狀檢查清單：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e✅ Console 印出 raw data 看起來正常\u003c/li\u003e\n\u003cli\u003e✅ \u003ccode\u003ejsonDecode\u003c/code\u003e 解碼成功\u003c/li\u003e\n\u003cli\u003e✅ Enum 對應的 JSON key (\u003ccode\u003e_Name_Ch\u003c/code\u003e) 完全相同\u003c/li\u003e\n\u003cli\u003e✅ 瀏覽器中直接訪問 API，\u003ccode\u003ename\u003c/code\u003e 確實有值\u003c/li\u003e\n\u003cli\u003e❌ Swift 中取得的 \u003ccode\u003ename\u003c/code\u003e 卻是 \u003ccode\u003enil\u003c/code\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e經過反覆檢查，終於發現問題根源：\u003cstrong\u003e不可見的 BOM (Byte-Order Mark) 字元\u003c/strong\u003e。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"什麼是-bom\"\u003e什麼是 BOM？\u003c/h2\u003e\n\u003cp\u003eBOM (Byte-Order Mark)，中文稱為\u003ca href=\"https://zh.wikipedia.org/zh-tw/%E7%AB%AF%E5%BA%8F%E8%A8%98%E8%99%9F\"\u003e位元組順序記號\u003c/a\u003e，是一個不可見的 Unicode 字元，用於標示文字檔的編碼位元組順序。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e常見的 BOM 字元：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eUTF-8 BOM\u003c/strong\u003e: \u003ccode\u003e0xEF 0xBB 0xBF\u003c/code\u003e (Unicode: \u003ccode\u003eU+FEFF\u003c/code\u003e)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eUTF-16 BE BOM\u003c/strong\u003e: \u003ccode\u003e0xFE 0xFF\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eUTF-16 LE BOM\u003c/strong\u003e: \u003ccode\u003e0xFF 0xFE\u003c/code\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"問題診斷\"\u003e問題診斷\u003c/h2\u003e\n\u003cp\u003e根據問題分析，\u003ccode\u003ename\u003c/code\u003e 為 \u003ccode\u003enil\u003c/code\u003e 的原因是：\u003c/p\u003e","title":"解決 API 回應中的 BOM (Byte-Order Mark) 字元問題"},{"content":"要叫出如下顏色樣本(swatch)\n關於顏色: Xcode中使用顏色樣本 要叫出如下顏色樣本(swatch)\nvar color = #colorLiteral(red: 0.3, blue: 0.2, green: 0.1, alpha: 1.0)打完如以上程式後按下enter 等於的後面就會自動轉成色塊\n參考: https://forums.developer.apple.com/forums/thread/690619\n","permalink":"https://peter-notes.com/posts/xcode-color-literals/","summary":"\u003cp\u003e要叫出如下顏色樣本(swatch)\u003c/p\u003e\n\u003ch3 id=\"關於顏色-xcode中使用顏色樣本\"\u003e關於顏色: Xcode中使用顏色樣本\u003c/h3\u003e\n\u003cp\u003e要叫出如下顏色樣本(swatch)\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://cdn-images-1.medium.com/max/800/1*HahosiqDhhahwjymbcTuqQ.png\"\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003evar\u003c/span\u003e \u003cspan class=\"nx\"\u003ecolor\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"err\"\u003e#\u003c/span\u003e\u003cspan class=\"nx\"\u003ecolorLiteral\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003ered\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"mf\"\u003e0.3\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003eblue\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"mf\"\u003e0.2\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003egreen\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"mf\"\u003e0.1\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003ealpha\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"mf\"\u003e1.0\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"nx\"\u003e打完如以上程式後按下enter\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e等於的後面就會自動轉成色塊\u003c/p\u003e\n\u003cp\u003e參考: \u003ca href=\"https://forums.developer.apple.com/forums/thread/690619\"\u003ehttps://forums.developer.apple.com/forums/thread/690619\u003c/a\u003e\u003c/p\u003e","title":"關於顏色: Xcode中使用顏色樣本"},{"content":"windows 傳檔案/程式 給Mac後, 在行尾的地方都會出現^M字符 導致git status都會出現被modify的狀態\n在MAC上消除 ^M 字符 windows 傳檔案/程式 給Mac後, 在行尾的地方都會出現^M字符 導致git status都會出現被modify的狀態\nGPT詠唱: **To restore all files in git repo to their state in the last commit **後出現\n方法之一**:\n$git restore — source=HEAD — staged — worktree — .** 解釋: 這個指令是使用 Git 命令行工具中的 “git restore” 命令。它的作用是將工作目錄中的檔案恢復到先前的狀態，並且可以選擇性地恢復到不同的地方。這句指令的意思如下：\n--source=HEAD: 指定恢復的來源是最新的提交（HEAD），即恢復到最近一次的提交狀態。 --staged: 將檔案的狀態恢復到暫存區（即將已經 \u0026ldquo;git add\u0026rdquo; 到暫存區的檔案恢復到暫存區的狀態）。 --worktree: 將檔案的實際內容恢復到工作目錄中，即恢復檔案的內容到實際修改之前的狀態。 .: 指定要恢復的檔案或目錄。在這裡的 . 代表當前目錄下的所有檔案。 總的來說，這個指令的作用是將暫存區中的檔案以及工作目錄中的檔案恢復到最近一次提交的狀態，並且將其內容恢復到修改之前的狀態。\n目前感覺最好用….(?)\n","permalink":"https://peter-notes.com/posts/remove-m-character-mac/","summary":"\u003cp\u003ewindows 傳檔案/程式 給Mac後, 在行尾的地方都會出現^M字符\n導致git status都會出現被modify的狀態\u003c/p\u003e\n\u003ch3 id=\"在mac上消除-m字符\"\u003e在MAC上消除 ^M 字符\u003c/h3\u003e\n\u003cp\u003ewindows 傳檔案/程式 給Mac後, 在行尾的地方都會出現^M字符\n導致git status都會出現被modify的狀態\u003c/p\u003e\n\u003cp\u003eGPT詠唱: **To restore all files in git repo to their state in the last commit\n**後出現\u003c/p\u003e\n\u003cp\u003e方法之一**:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003e$git\u003c/span\u003e restore — \u003cspan class=\"nv\"\u003esource\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003eHEAD — staged — worktree — .**\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e解釋:\n這個指令是使用 Git 命令行工具中的 “git restore” 命令。它的作用是將工作目錄中的檔案恢復到先前的狀態，並且可以選擇性地恢復到不同的地方。這句指令的意思如下：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003e--source=HEAD\u003c/code\u003e: 指定恢復的來源是最新的提交（HEAD），即恢復到最近一次的提交狀態。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e--staged\u003c/code\u003e: 將檔案的狀態恢復到暫存區（即將已經 \u0026ldquo;git add\u0026rdquo; 到暫存區的檔案恢復到暫存區的狀態）。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e--worktree\u003c/code\u003e: 將檔案的實際內容恢復到工作目錄中，即恢復檔案的內容到實際修改之前的狀態。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e.\u003c/code\u003e: 指定要恢復的檔案或目錄。在這裡的 \u003ccode\u003e.\u003c/code\u003e 代表當前目錄下的所有檔案。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e總的來說，這個指令的作用是將暫存區中的檔案以及工作目錄中的檔案恢復到最近一次提交的狀態，並且將其內容恢復到修改之前的狀態。\u003c/p\u003e\n\u003cp\u003e目前感覺最好用….(?)\u003c/p\u003e","title":"在MAC上消除 ^M 字符"},{"content":"在終端機下兩行指令搞定FortiClient.app被鎖住不能移除的問題\n從MAC移除 FortiClient.app 在終端機下兩行指令搞定FortiClient.app被鎖住不能移除的問題\n# 第一步 /bin/ls -dleO@ /Applications/FortiClient.app # 第二步 sudo /usr/bin/chflags -R noschg /Applications/FortiClient.app執行完上面的指令後，小鎖就不見了 然後就可以用AppCleaner把App完整刪除．\n這兩個命令的目的是在Unix/Linux系統中設置和查看文件或目錄的標誌（flags）。其中，chflags命令用於更改標誌，而ls命令則用於列出文件或目錄的詳細信息，包括標誌。\n/Applications/FortiClient.app上的小鎖可能是由於該應用程序被設置為“不可更改”（immutable）的屬性，這是一種特殊的標誌。執行chflags -R noschg /Applications/FortiClient.app命令後，將這個“不可更改”標誌移除了，因此小鎖消失了。\n通常情況下，應用程序的小鎖表示這個應用程序的文件或目錄具有某種特殊的權限或屬性，可能是為了保護或限制對應用程序的修改。當您使用chflags命令修改了應用程序的標誌後，小鎖就消失了，這表明相關的權限或屬性已經被修改或移除了。\n第一行指令的含義：\n/bin/ls -dleO@ /Applications/FortiClient.app /bin/ls：是列出目錄內容的命令，/bin是ls命令所在的路徑。 -dleO@：是命令的選項或參數，它們分別具有以下含義： -d：指定了要列出的目錄的詳細信息，而不是目錄內容的詳細信息。 -l：以長格式（long format）顯示文件的詳細信息，包括文件類型、權限、所有者、群組、大小、修改日期等。 -e：顯示Access Control List (ACL) 的相關信息。 -O：顯示擴展屬性（extended attributes）的相關信息。 @：顯示文件的扩展屬性，這在macOS中通常與App Store中的應用程序相關。 /Applications/FortiClient.app：是指定的文件或目錄的路徑，這裡是指定了FortiClient應用程序的路徑。 第二行指令的含義：\nsudo /usr/bin/chflags -R noschg /Applications/FortiClient.app sudo：用於以超級用戶權限執行命令，通常用於需要特權權限的操作。 /usr/bin/chflags：是用於更改文件或目錄標誌的命令，/usr/bin是chflags命令所在的路徑。 -R：表示遞歸地（recursive）應用這個操作到指定目錄及其所有子目錄和文件。 noschg：是要設置的標誌，noschg表示“不可更改”（no change），這意味著對指定的文件或目錄禁止了修改或刪除的操作。 /Applications/FortiClient.app：是指定的文件或目錄的路徑，這裡是指定了FortiClient應用程序的路徑。 ","permalink":"https://peter-notes.com/posts/remove-forticlient-mac/","summary":"\u003cp\u003e在終端機下兩行指令搞定FortiClient.app被鎖住不能移除的問題\u003c/p\u003e\n\u003ch3 id=\"從mac移除-forticlientapp\"\u003e從MAC移除 FortiClient.app\u003c/h3\u003e\n\u003cp\u003e在終端機下兩行指令搞定FortiClient.app被鎖住不能移除的問題\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 第一步\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e/bin/ls -dleO@ /Applications/FortiClient.app\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 第二步\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003esudo /usr/bin/chflags -R noschg /Applications/FortiClient.app執行完上面的指令後，小鎖就不見了\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e然後就可以用AppCleaner把App完整刪除．\u003c/p\u003e\n\u003cp\u003e這兩個命令的目的是在Unix/Linux系統中設置和查看文件或目錄的標誌（flags）。其中，\u003ccode\u003echflags\u003c/code\u003e命令用於更改標誌，而\u003ccode\u003els\u003c/code\u003e命令則用於列出文件或目錄的詳細信息，包括標誌。\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003e/Applications/FortiClient.app\u003c/code\u003e上的小鎖可能是由於該應用程序被設置為“不可更改”（immutable）的屬性，這是一種特殊的標誌。執行\u003ccode\u003echflags -R noschg /Applications/FortiClient.app\u003c/code\u003e命令後，將這個“不可更改”標誌移除了，因此小鎖消失了。\u003c/p\u003e\n\u003cp\u003e通常情況下，應用程序的小鎖表示這個應用程序的文件或目錄具有某種特殊的權限或屬性，可能是為了保護或限制對應用程序的修改。當您使用\u003ccode\u003echflags\u003c/code\u003e命令修改了應用程序的標誌後，小鎖就消失了，這表明相關的權限或屬性已經被修改或移除了。\u003c/p\u003e\n\u003cp\u003e第一行指令的含義：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e/bin/ls -dleO@ /Applications/FortiClient.app\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cul\u003e\n\u003cli\u003e\u003ccode\u003e/bin/ls\u003c/code\u003e：是列出目錄內容的命令，\u003ccode\u003e/bin\u003c/code\u003e是\u003ccode\u003els\u003c/code\u003e命令所在的路徑。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e-dleO@\u003c/code\u003e：是命令的選項或參數，它們分別具有以下含義：\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e-d\u003c/code\u003e：指定了要列出的目錄的詳細信息，而不是目錄內容的詳細信息。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e-l\u003c/code\u003e：以長格式（long format）顯示文件的詳細信息，包括文件類型、權限、所有者、群組、大小、修改日期等。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e-e\u003c/code\u003e：顯示Access Control List (ACL) 的相關信息。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e-O\u003c/code\u003e：顯示擴展屬性（extended attributes）的相關信息。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e@\u003c/code\u003e：顯示文件的扩展屬性，這在macOS中通常與App Store中的應用程序相關。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e/Applications/FortiClient.app\u003c/code\u003e：是指定的文件或目錄的路徑，這裡是指定了FortiClient應用程序的路徑。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e第二行指令的含義：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003esudo /usr/bin/chflags -R noschg /Applications/FortiClient.app\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cul\u003e\n\u003cli\u003e\u003ccode\u003esudo\u003c/code\u003e：用於以超級用戶權限執行命令，通常用於需要特權權限的操作。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e/usr/bin/chflags\u003c/code\u003e：是用於更改文件或目錄標誌的命令，\u003ccode\u003e/usr/bin\u003c/code\u003e是\u003ccode\u003echflags\u003c/code\u003e命令所在的路徑。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e-R\u003c/code\u003e：表示遞歸地（recursive）應用這個操作到指定目錄及其所有子目錄和文件。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003enoschg\u003c/code\u003e：是要設置的標誌，\u003ccode\u003enoschg\u003c/code\u003e表示“不可更改”（no change），這意味著對指定的文件或目錄禁止了修改或刪除的操作。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e/Applications/FortiClient.app\u003c/code\u003e：是指定的文件或目錄的路徑，這裡是指定了FortiClient應用程序的路徑。\u003c/li\u003e\n\u003c/ul\u003e","title":"從MAC移除 FortiClient.app"},{"content":".contains(物件)為真，卻沒有被執行？Why ?\ncontains .contains(物件)為真，卻沒有被執行？Why ?\nAI解釋：在您提到的情況下，當您按下 IconButton 時，favorites 列表中已經包含了該 Movie 物件，但是在第 161 行的 else 塊中的代碼沒有被執行。這可能是因為在第 160 行中，您使用了 contains 方法來檢查 favorites 列表中是否包含該 Movie 物件，但是 contains 方法是基於 == 運算符來比較兩個對象是否相等的。如果您的 Movie 類沒有實現 == 運算符，則 contains 方法將無法正確地比較兩個 Movie 物件是否相等。\n為了解決這個問題，您可以在 Movie 類中實現 == 運算符，以便在比較兩個 Movie 物件時使用。\n最後在movie.dart加上這段解決\n","permalink":"https://peter-notes.com/posts/swift-contains/","summary":"\u003cp\u003e.contains(物件)為真，卻沒有被執行？Why ?\u003c/p\u003e\n\u003ch3 id=\"contains\"\u003econtains\u003c/h3\u003e\n\u003cp\u003e.contains(物件)為真，卻沒有被執行？Why ?\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"/images/medium/contains/image_8bd0cbb5.png\"\u003e\u003c/p\u003e\n\u003cp\u003eAI解釋：在您提到的情況下，當您按下 \u003ccode\u003eIconButton\u003c/code\u003e 時，\u003ccode\u003efavorites\u003c/code\u003e 列表中已經包含了該 \u003ccode\u003eMovie\u003c/code\u003e 物件，但是在第 161 行的 \u003ccode\u003eelse\u003c/code\u003e 塊中的代碼沒有被執行。這可能是因為在第 160 行中，您使用了 \u003ccode\u003econtains\u003c/code\u003e 方法來檢查 \u003ccode\u003efavorites\u003c/code\u003e 列表中是否包含該 \u003ccode\u003eMovie\u003c/code\u003e 物件，但是 \u003ccode\u003econtains\u003c/code\u003e 方法是基於 \u003ccode\u003e==\u003c/code\u003e 運算符來比較兩個對象是否相等的。如果您的 \u003ccode\u003eMovie\u003c/code\u003e 類沒有實現 \u003ccode\u003e==\u003c/code\u003e 運算符，則 \u003ccode\u003econtains\u003c/code\u003e 方法將無法正確地比較兩個 \u003ccode\u003eMovie\u003c/code\u003e 物件是否相等。\u003c/p\u003e\n\u003cp\u003e為了解決這個問題，您可以在 \u003ccode\u003eMovie\u003c/code\u003e 類中實現 \u003ccode\u003e==\u003c/code\u003e 運算符，以便在比較兩個 \u003ccode\u003eMovie\u003c/code\u003e 物件時使用。\u003c/p\u003e\n\u003cp\u003e最後在movie.dart加上這段解決\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"/images/medium/contains/image_8d752a64.png\"\u003e\u003c/p\u003e","title":"contains"},{"content":"前言 使用 Apple Silicon Mac 開發前端專案時，你有沒有遇過這種情況：npm install 明明成功了，但執行 npm run dev 時 Vite 卻突然翻臉不認人，噴出一堆關於 rollup.darwin-arm64.node 的錯誤？\n更慘的是，macOS 還會彈出一個視窗說「無法打開 rollup.darwin-arm64.node，因為無法驗證開發者」——彷彿你的電腦在暗示你下載了什麼奇怪的東西。\n別擔心，這不是你的問題（好吧，也不完全是 Apple 的問題），而是 npm + Gatekeeper 的經典組合技。本文將帶你徹底理解並解決這個惱人的問題。\n問題現象 當你在 Apple Silicon Mac 上執行 Vite/Rollup 專案時，可能會遇到以下錯誤：\n錯誤 1：模組找不到 Error: Cannot find module \u0026#39;@rollup/rollup-darwin-arm64\u0026#39; Require stack: - /project/node_modules/rollup/dist/native.js 錯誤 2：系統策略拒絕載入 Error: dlopen(/project/node_modules/@rollup/rollup-darwin-arm64/rollup.darwin-arm64.node, 0x0001): code signature in (...) not valid for use in process: library load disallowed by system policy 錯誤 3：Gatekeeper 彈窗警告 macOS 直接彈出視窗：「無法打開 rollup.darwin-arm64.node，因為無法驗證開發者」\n問題根源分析 這個問題其實是兩個獨立問題的組合：\n原因 1：npm optionalDependencies Bug Rollup 使用 optionalDependencies 來管理多平台的原生二進位檔：\n@rollup/rollup-darwin-arm64 (Apple Silicon Mac) @rollup/rollup-darwin-x64 (Intel Mac) @rollup/rollup-linux-x64 (Linux) @rollup/rollup-win32-x64-msvc (Windows) 但 npm 有個已知 Bug：在某些情況下會漏裝或裝錯平台版本的 optional dependency，導致模組找不到。\n原因 2：macOS Gatekeeper 隔離機制 macOS 會對從網路下載的可執行檔打上 com.apple.quarantine 隔離標籤。當 Node.js 嘗試載入這些 .node 原生模組時，Gatekeeper 會介入檢查，如果無法驗證簽章就會阻擋。\n有趣的是：npm 下載的檔案會被標記為「從網路下載」，即使它來自官方 registry。這就是為什麼明明是合法套件，macOS 還是會擋。\n快速修復步驟 90% 的情況，以下四個步驟就能解決問題：\nStep 1：刪除舊依賴與鎖檔 rm -rf node_modules package-lock.json Step 2：重新安裝所有依賴 npm install Step 3：清除 Gatekeeper 隔離標籤 xattr -dr com.apple.quarantine node_modules/@rollup/rollup-darwin-arm64/*.node 如果你不確定檔案在哪，可以用：\nfind node_modules -name \u0026#34;*.node\u0026#34; -exec xattr -dr com.apple.quarantine {} \\; Step 4：重建原生模組（可選） npm rebuild rollup 執行完以上步驟，通常就能順利 npm run dev 了。\n進階解決方案 如果快速修復無效，或你想要更長久的解決方案，以下是幾個選項：\n方案 A：升級 npm npm v9.x 以上對 optionalDependencies 的處理更穩定：\nnpm install -g npm@latest npm --version # 確認版本 \u0026gt;= 9.x 方案 B：改用 Yarn 或 PNPM 這兩個套件管理器對 optionalDependencies 的支援更完整：\n# 使用 PNPM（推薦） npm install -g pnpm pnpm install # 或使用 Yarn npm install -g yarn yarn install 方案 C：強制使用 JavaScript Fallback Rollup 有純 JavaScript 實作，雖然較慢但完全不需要原生模組：\n# 單次執行 ROLLUP_IGNORE_NATIVE=1 npm run dev # 或加入 .env 檔案 echo \u0026#34;ROLLUP_IGNORE_NATIVE=1\u0026#34; \u0026gt;\u0026gt; .env 方案 D：在 .npmrc 關閉 optionalDependencies # .npmrc optional=false 這會讓 npm 完全跳過所有 optional dependencies，Rollup 會自動退回 JS 實作。\n方案 E：自動化清除 quarantine（團隊協作推薦） 在 package.json 加入 postinstall 腳本：\n{ \u0026#34;scripts\u0026#34;: { \u0026#34;postinstall\u0026#34;: \u0026#34;xattr -dr com.apple.quarantine node_modules/**/*.node 2\u0026gt;/dev/null || true\u0026#34; } } 這樣每次 npm install 後都會自動清除隔離標籤。\n各方案比較 方案 優點 缺點 適用場景 重新安裝 簡單快速 治標不治本 臨時解決 升級 npm 根本解決 可能影響其他專案 個人開發 改用 PNPM 效能更好、更穩定 需要團隊配合 新專案 JS Fallback 完全避開問題 打包速度較慢 CI/CD 環境 postinstall 自動化 只對 Mac 有效 團隊協作 預防措施 在專案 README 記錄 ## macOS Apple Silicon 用戶注意 如果遇到 Rollup 相關錯誤，請執行： \\`\\`\\`bash rm -rf node_modules package-lock.json npm install xattr -dr com.apple.quarantine node_modules/**/*.node \\`\\`\\` 在 CI/CD 中使用環境變數 # .gitlab-ci.yml 或 GitHub Actions env: ROLLUP_IGNORE_NATIVE: 1 結論 macOS Gatekeeper + npm optionalDependencies Bug 是 Apple Silicon Mac 開發者的常見痛點。好消息是，這個問題有明確的解決方案：\nTL;DR 快速解法：\nrm -rf node_modules package-lock.json \u0026amp;\u0026amp; npm install \u0026amp;\u0026amp; xattr -dr com.apple.quarantine node_modules/**/*.node 長期解法：\n改用 PNPM 作為套件管理器 或在 package.json 加入 postinstall 自動清除腳本 希望這篇文章能幫你省下幾小時的 debug 時間。畢竟人生苦短，不該浪費在跟 Gatekeeper 吵架上。\n參考資源 Rollup GitHub Issues - darwin-arm64 npm optionalDependencies 文件 Apple Gatekeeper 官方說明 ","permalink":"https://peter-notes.com/posts/fix-rollup-darwin-arm64-error/","summary":"\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e使用 Apple Silicon Mac 開發前端專案時，你有沒有遇過這種情況：\u003ccode\u003enpm install\u003c/code\u003e 明明成功了，但執行 \u003ccode\u003enpm run dev\u003c/code\u003e 時 Vite 卻突然翻臉不認人，噴出一堆關於 \u003ccode\u003erollup.darwin-arm64.node\u003c/code\u003e 的錯誤？\u003c/p\u003e\n\u003cp\u003e更慘的是，macOS 還會彈出一個視窗說「無法打開 rollup.darwin-arm64.node，因為無法驗證開發者」——彷彿你的電腦在暗示你下載了什麼奇怪的東西。\u003c/p\u003e\n\u003cp\u003e別擔心，這不是你的問題（好吧，也不完全是 Apple 的問題），而是 npm + Gatekeeper 的經典組合技。本文將帶你徹底理解並解決這個惱人的問題。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"問題現象\"\u003e問題現象\u003c/h2\u003e\n\u003cp\u003e當你在 Apple Silicon Mac 上執行 Vite/Rollup 專案時，可能會遇到以下錯誤：\u003c/p\u003e\n\u003ch3 id=\"錯誤-1模組找不到\"\u003e錯誤 1：模組找不到\u003c/h3\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eError: Cannot find module \u0026#39;@rollup/rollup-darwin-arm64\u0026#39;\nRequire stack:\n- /project/node_modules/rollup/dist/native.js\n\u003c/code\u003e\u003c/pre\u003e\u003ch3 id=\"錯誤-2系統策略拒絕載入\"\u003e錯誤 2：系統策略拒絕載入\u003c/h3\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eError: dlopen(/project/node_modules/@rollup/rollup-darwin-arm64/rollup.darwin-arm64.node, 0x0001):\ncode signature in (...) not valid for use in process: library load disallowed by system policy\n\u003c/code\u003e\u003c/pre\u003e\u003ch3 id=\"錯誤-3gatekeeper-彈窗警告\"\u003e錯誤 3：Gatekeeper 彈窗警告\u003c/h3\u003e\n\u003cp\u003emacOS 直接彈出視窗：「無法打開 rollup.darwin-arm64.node，因為無法驗證開發者」\u003c/p\u003e","title":"解決 macOS 上 Rollup rollup.darwin-arm64.node 模組錯誤與 Gatekeeper 阻擋問題"},{"content":"在iOS app中，記憶體管理是基於引用計數模型運作。當創建一個物件的時候，記憶體會在Heap(堆)上分配，並將其引用計數設置為1。隨著其他物件對此物件建立強引用(strong reference)，其引用計數會增加1。\niOS : 記憶體管理 在iOS app中，記憶體管理是基於引用計數模型運作。當創建一個物件的時候，記憶體會在Heap(堆)上分配，並將其引用計數設置為1。隨著其他物件對此物件建立強引用(strong reference)，其引用計數會增加1。\n相反的，如果擁有物件的持有者放棄了強引用，引用計數將會減少1。一旦引用計數變為0，該物件的記憶體就會被自動釋放。\n當啟用了ARC(Automatic Reference Counting)自動引用計數功能的編譯器編寫程式碼時，編譯器會分析你創建的引用，並自動插入對底層記憶體管理機制的調用，我們無須手動設置引用計數。\n隨著自動引用計數(ARC)的引入，我們只需要在引用物件時指定所有權的類型：\n強引用(strong reference)：確保被引用的物件只要引用仍然有效，就會一直保留在記憶體中。例如，我們宣告一個控制器屬性 strong var myView: UIView，表示控制器強引用 myView 物件，直到控制器釋放之前，myView 都會存在記憶體中。 弱引用 (weak reference)：對被引用的物件的生存期沒有影響。例如，我們宣告一個閉包中的局部變數 weak var capturedView: UIView，表示閉包弱引用 capturedView 物件，即使 capturedView 被釋放，閉包中的 capturedView 也會變成 nil 而不會崩潰。 非擁有引用 (unowned reference)：與弱引用類似，對被引用的物件的生存期沒有影響，但與弱引用的不同之處是，非擁有引用 預期總是擁有 「非 nil 的值」，**ARC 不會自動將其設置為 ****nil**。例如，我們宣告一個子視圖中的屬性 unowned var parentViewController: UIViewController，表示子視圖非擁有引用父控制器，而父控制器通常擁有比子視圖更長的生存期，因此子視圖可以安全地訪問父控制器。 需要注意的是：\n當被引用的物件被釋放時，弱引用會被設定為 nil，而非擁有引用則會變成一個懸浮指標 (dangling pointer)。向懸浮指標發送訊息會導致程式崩潰。 使用非擁有引用時，要確保另一個物件擁有相同的或更長的生存期，避免懸浮指標問題。 簡單來說，在以下情況下使用不同的引用類型：\nstrong: 當你想要確保物件一直存在，直到不再需要它為止； weak: 會在被引用的物件被釋放時自動設為 nil，因此它們可以用來避免循環引用。例如，在代理關係中，委托者通常會使用弱引用來引用代理，這樣當委托者被釋放時，代理也不會被保留。； unowned: 不會在被引用的物件被釋放時自動設為 nil，因此它們只能用於指向那些生命週期一定會比引用它的物件長的物件。例如，子視圖可以使用非擁有引用來引用父視圖，因為父視圖通常會比子視圖存在得更久。 assign: 當你只需要一個指向簡單數據的指標，不需要跟踪它的生命周期。 使用 weak 和 assign 引用可以避免循環引用，即兩個物件互相引用導致彼此都無法被釋放的情況。\n使用弱引用來避免循環引用： class Delegate { weak var delegate: SomeObject? }\nclass SomeObject { weak var delegate: Delegate? }在上述例子中，Delegate 和 SomeObject 之間存在循環引用。如果使用強引用，則兩個物件都會一直存在，直到程式結束。使用弱引用後，當 Delegate 被釋放時，delegate 指標會變為 nil，從而打破循環引用。\n使用非擁有引用來指向父視圖： class ViewController: UIViewController { unowned var childView: UIView\ninit(childView: UIView) { self.childView = childView } }在上述例子中，ViewController 使用非擁有引用來引用 childView。因為 ViewController 的生命週期通常會比 childView 短，因此 childView 不會在 ViewController 被釋放時被釋放。\n總而言之，在選擇使用弱引用還是非擁有引用時，需要考慮以下因素：\n是否需要避免循環引用？ 如果需要，則使用弱引用。 被引用的物件的生命週期是否一定會比引用它的物件長？ 如果是，則使用非擁有引用。 避免強引用循環：\n如果對 ARC（自動引用計數）背後的引用計數機制有良好的理解，那麼理解引用循環的概念就會很容易。如果兩個物件之間存在一個由強引用構成的循環，則即使沒有其他強引用指向它們，它們也會相互保持對方的存活，導致無法被釋放。\n閉包/Block 中的強引用循環\n在 iOS 開發中，常見的強引用循環之一發生在使用閉包/Block 時。如果將閉包/Block 分配給類實例的屬性，並且閉包/Block 的內部捕獲了該實例（self），則可能會導致強引用循環。需要注意的是，如果只是創建一個新的閉包/Block 而不將其分配給屬性，則不會導致任何引用循環。\n更詳細的解釋如下：\n閉包/Block 是一種可以捕獲外部變量的程式碼塊。在 Swift 和 Objective-C 中，閉包/Block 會隱式地捕獲它們所使用的所有變量，包括 self 指標。 強引用循環 是指兩個或多個物件互相保持強引用，導致它們無法被釋放的情況。 閉包/Block 中的強引用循環示例：\nclass MyClass { var closure: () -\u0026gt; Void = { // 這個閉包捕獲了 self，可能導致強引用循環 print(self) } }\nlet myObject = MyClass() // 這裡可能會導致強引用循環如何避免閉包/Block 中的強引用循環： 使用弱引用 (weak) 或非擁有引用 (unowned)： 在閉包/Block 內部使用 weak 或 unowned 關鍵字來引用 self，可以避免強引用循環。 使用捕獲列表 (capture list)： 在閉包/Block 的定義中使用捕獲列表，可以明確指定要捕獲的變量，並可以指定它們是強引用還是弱引用。 示例：\nclass MyClass { var closure: () -\u0026gt; Void = { [weak self] in // 使用弱引用來引用 self guard let strongSelf = self else { return } print(strongSelf) } }在使用閉包/Block 時，需要注意避免強引用循環。使用弱引用或非擁有引用以及捕獲列表是常用的避免強引用循環的方法。\n在 Objective-C 中使用 Copy 属性 在 Objective-C 的實踐中，對於像 NSString 和 NSArray 這樣具有可變版本的類，我們通常會使用 copy 属性。這麼做的原因是為了確保我們的属性擁有獨立的副本，不會受到原始可變變數更新的影響。\n更詳細的解釋如下：\nCopy 属性: 當你將一個物件賦值給具有 copy 属性的變量時，會自動複製該物件，並將副本賦值給變數。這意味著變數持有的是原始物件的一個獨立副本，而不是直接指向原始物件。 可變版本類: 像 NSString 和 NSArray 這樣的類，既有不可變版本（NSString、NSArray），也有可變版本（NSMutableString、NSMutableArray）。可變版本的物件可以被修改，而不可變版本的物件則不能被修改。 為何要使用 Copy 屬性呢？\n保護數據完整性: 當你將一個可變物件賦值給一個屬性時，如果不使用 copy，那麼該屬性就會直接指向原始物件。如果之後原始物件被修改，那麼屬性所指向的物件也會被修改，導致數據不一致。使用 copy 屬性可以避免這個問題，因為它會創建一個獨立的副本，就算原始物件被修改，属性所指向的副本也不會受到影響。 防止意外修改: 在某些情况下，你可能希望確保一個屬性不可被修改。例如，你可能有一个表示用戶名的屬性，你不希望其他人可以修改它。在這種情况下，可以使用 copy 屬性來確保属性值不可被修改。 示例：\n@property (nonatomic, copy) NSString *name; @property (nonatomic, copy) NSArray *items;在 Objective-C 中，對於具有可變版本的類，通常建議使用 copy 屬性来保護資料的完整性和防止意外修改。\n電腦記憶體中的五大區域：\n棧/stack : 後進先出(LIFO)的儲存結構，用於存放函數的局部變量、參數和返回地址。. 堆/heap : 用於存放動態分配的記憶體。先進後出的(FILO)的儲存結構，工程師手動申請的字節空間 malloc calloc realoc函數. 當需要分配記憶體時，可以使用malloc()或calloc()函數從堆中分配記憶體。當不需要使用記憶體時，可以使用free()函數釋放記憶體。 BSS段/Block Started by Symbol : 儲存未被初始化的全局變量 靜態變數. BSS段的內容在程序啟動時會被初始化為0。 常數段/constant segment : 儲存已被初始化的全局 靜態變量 常量資料. 代碼段/ code segment : 儲存程式的代碼. **C 語言程式記憶體配置 (LibreOffice 原始檔)**Reference: Apple官方文件: https://docs.swift.org/swift-book/documentation/the-swift-programming-language/automaticreferencecounting/\n","permalink":"https://peter-notes.com/posts/ios-memory-management/","summary":"\u003cp\u003e在iOS app中，記憶體管理是基於引用計數模型運作。當創建一個物件的時候，記憶體會在Heap(堆)上分配，並將其引用計數設置為1。隨著其他物件對此物件建立強引用(strong reference)，其引用計數會增加1。\u003c/p\u003e\n\u003ch3 id=\"ios記憶體管理\"\u003eiOS : 記憶體管理\u003c/h3\u003e\n\u003cp\u003e在iOS app中，記憶體管理是基於引用計數模型運作。當創建一個物件的時候，記憶體會在Heap(堆)上分配，並將其引用計數設置為1。隨著其他物件對此物件建立強引用(strong reference)，其引用計數會增加1。\u003c/p\u003e\n\u003cp\u003e相反的，如果擁有物件的持有者放棄了強引用，引用計數將會減少1。一旦引用計數變為0，該物件的記憶體就會被自動釋放。\u003c/p\u003e\n\u003cp\u003e當啟用了ARC(Automatic Reference Counting)自動引用計數功能的編譯器編寫程式碼時，編譯器會分析你創建的引用，並自動插入對底層記憶體管理機制的調用，我們無須手動設置引用計數。\u003c/p\u003e\n\u003cp\u003e隨著自動引用計數(ARC)的引入，我們只需要在引用物件時指定所有權的類型：\u003c/p\u003e\n\u003ch1 id=\"強引用strong-reference確保被引用的物件只要引用仍然有效就會一直保留在記憶體中例如我們宣告一個控制器屬性-strong-var-myview-uiview表示控制器強引用-myview-物件直到控制器釋放之前myview-都會存在記憶體中\"\u003e\u003cstrong\u003e強引用\u003c/strong\u003e(strong reference)：確保被引用的物件只要引用仍然有效，就會一直保留在記憶體中。例如，我們宣告一個控制器屬性 \u003ccode\u003estrong var myView: UIView\u003c/code\u003e，表示控制器強引用 \u003ccode\u003emyView\u003c/code\u003e 物件，直到控制器釋放之前，myView 都會存在記憶體中。\u003c/h1\u003e\n\u003ch1 id=\"弱引用-weak-reference對被引用的物件的生存期沒有影響例如我們宣告一個閉包中的局部變數-weak-var-capturedview-uiview表示閉包弱引用-capturedview-物件即使-capturedview-被釋放閉包中的-capturedview-也會變成-nil-而不會崩潰\"\u003e\u003cstrong\u003e弱引用\u003c/strong\u003e (weak reference)：對被引用的物件的生存期沒有影響。例如，我們宣告一個閉包中的局部變數 \u003ccode\u003eweak var capturedView: UIView\u003c/code\u003e，表示閉包弱引用 \u003ccode\u003ecapturedView\u003c/code\u003e 物件，即使 \u003ccode\u003ecapturedView\u003c/code\u003e 被釋放，閉包中的 \u003ccode\u003ecapturedView\u003c/code\u003e 也會變成 \u003ccode\u003enil\u003c/code\u003e 而不會崩潰。\u003c/h1\u003e\n\u003ch1 id=\"非擁有引用-unowned-reference與弱引用類似對被引用的物件的生存期沒有影響但與弱引用的不同之處是非擁有引用-預期總是擁有-非-nil-的值arc-不會自動將其設置為-nil例如我們宣告一個子視圖中的屬性-unowned-var-parentviewcontroller-uiviewcontroller表示子視圖非擁有引用父控制器而父控制器通常擁有比子視圖更長的生存期因此子視圖可以安全地訪問父控制器\"\u003e\u003cstrong\u003e非擁有引用\u003c/strong\u003e (unowned reference)：與弱引用類似，對被引用的物件的生存期沒有影響，但與弱引用的不同之處是，非擁有引用 預期總是擁有 「非 \u003ccode\u003enil\u003c/code\u003e 的值」，**ARC 不會自動將其設置為 **\u003ccode\u003e**nil**\u003c/code\u003e。例如，我們宣告一個子視圖中的屬性 \u003ccode\u003eunowned var parentViewController: UIViewController\u003c/code\u003e，表示子視圖非擁有引用父控制器，而父控制器通常擁有比子視圖更長的生存期，因此子視圖可以安全地訪問父控制器。\u003c/h1\u003e\n\u003cp\u003e需要注意的是：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e當被引用的物件被釋放時，弱引用會被設定為 \u003ccode\u003enil\u003c/code\u003e，而非擁有引用則會變成一個懸浮指標 (dangling pointer)。向懸浮指標發送訊息會導致程式崩潰。\u003c/li\u003e\n\u003cli\u003e使用非擁有引用時，要確保另一個物件擁有相同的或更長的生存期，避免懸浮指標問題。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e簡單來說，在以下情況下使用不同的引用類型：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003estrong: 當你想要確保物件一直存在，直到不再需要它為止；\u003c/li\u003e\n\u003cli\u003eweak: 會在被引用的物件被釋放時自動設為 \u003ccode\u003enil\u003c/code\u003e，因此它們可以用來避免循環引用。例如，在代理關係中，委托者通常會使用弱引用來引用代理，這樣當委托者被釋放時，代理也不會被保留。；\u003c/li\u003e\n\u003cli\u003eunowned: 不會在被引用的物件被釋放時自動設為 \u003ccode\u003enil\u003c/code\u003e，因此它們只能用於指向那些生命週期一定會比引用它的物件長的物件。例如，子視圖可以使用非擁有引用來引用父視圖，因為父視圖通常會比子視圖存在得更久。\u003c/li\u003e\n\u003cli\u003eassign: 當你只需要一個指向簡單數據的指標，不需要跟踪它的生命周期。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e使用 weak 和 assign 引用可以避免循環引用，即兩個物件互相引用導致彼此都無法被釋放的情況。\u003c/p\u003e","title":"iOS : 記憶體管理"},{"content":"打開設定(Settings)\nVSCode 顯示隱藏資料夾 打開設定(Settings)\n在設定畫面右上角點擊“Open Settings按鈕”\n添加設置：\n存檔，從新啟動VSCode，就可以看到 . 開頭的資料夾\n","permalink":"https://peter-notes.com/posts/vscode-show-hidden-folders/","summary":"\u003cp\u003e打開設定(Settings)\u003c/p\u003e\n\u003ch3 id=\"vscode-顯示隱藏資料夾\"\u003eVSCode 顯示隱藏資料夾\u003c/h3\u003e\n\u003cp\u003e打開設定(Settings)\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://cdn-images-1.medium.com/max/800/1*nlFqn2injFLMFrA1lU4EHA.jpeg\"\u003e\u003c/p\u003e\n\u003cp\u003e在設定畫面右上角點擊“Open Settings按鈕”\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://cdn-images-1.medium.com/max/800/1*2uoe8c9U3L_XMZW_-4aXAA.jpeg\"\u003e\u003c/p\u003e\n\u003cp\u003e添加設置：\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://cdn-images-1.medium.com/max/800/1*qSCEolQlipwx7dhCT-467Q.jpeg\"\u003e\u003c/p\u003e\n\u003cp\u003e存檔，從新啟動VSCode，就可以看到 . 開頭的資料夾\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"/images/medium/vscode-%E9%A1%AF%E7%A4%BA%E9%9A%B1%E8%97%8F%E8%B3%87%E6%96%99%E5%A4%BE/image_616b6fb7.jpeg\"\u003e\u003c/p\u003e","title":"VSCode 顯示隱藏資料夾"},{"content":"它們在使用和用途上有很大的區別。讓我們來解釋這兩者之間的差異：\niOS中的associated type和associated value是不同的概念 它們在使用和用途上有很大的區別。讓我們來解釋這兩者之間的差異：\n是的，iOS中的associated type和associated value是不同的概念，它們在使用和用途上有很大的區別。讓我們來解釋這兩者之間的差異：\nAssociated Type（關聯類型）：\nAssociated type是Swift中協議（protocols）的一個特性。\n關聯類型允許在定義協議時指定一個或多個類型，但不需要提供具體的實現。\n具體遵循（conforming）該協議的類別或結構體需要提供關聯類型的具體實現。\n關聯類型使協議更具通用性，因為它可以適應不同的類型。\nprotocol OrderQueue { associatedtype Order // 定義一個關聯類型 mutating func enqueue(_ order: Order) mutating func dequeue() -\u0026gt; Order? }\nstruct OrderQueueImplementation: OrderQueue { // 實現OrderQueue協議，指定關聯類型為OrderStatus typealias Order = OrderStatus private var orders: [OrderStatus] = []\nmutating func enqueue(_ order: OrderStatus) { orders.append(order) } mutating func dequeue() -\u0026gt; OrderStatus? { return orders.isEmpty ? nil : orders.removeFirst() } }\nvar orderQueue = OrderQueueImplementation() orderQueue.enqueue(.pending) orderQueue.enqueue(.processing(trackingNumber: \u0026#34;123456\u0026#34;)) let dequeuedOrder = orderQueue.dequeue() switch dequeuedOrder { case .some(.pending): print(\u0026ldquo;已出列的訂單狀態為等待處理\u0026rdquo;) case .some(.processing(let trackingNumber)): print(\u0026ldquo;已出列的訂單狀態為處理中，追蹤編號：(trackingNumber)\u0026rdquo;) case .none: print(\u0026ldquo;佇列是空的\u0026rdquo;) default: print(\u0026ldquo;其他訂單狀態\u0026rdquo;) }在這個示例中，我們定義了一個 OrderQueue 協議，並使用 associated type Order 來指定佇列中的元素類型，即 OrderStatus。然後，我們創建了一個 OrderQueueImplementation 結構體，它實現了 OrderQueue 協議並指定了 Order 的具體類型。這使得我們可以在 OrderQueue 中處理不同類型的訂單狀態，就像在 OrderStatus 枚舉中一樣。\n官網參考Apple Swift Programming Doc : Associated Typesin page link\nAssociated Value（關聯值）： Associated value通常是指Swift中的枚舉（enum）值的一個特性。 關聯值允許你將額外的資訊與枚舉的特定情況（case）關聯起來。 這對於定義具有不同狀態的對象以及與之相關的數據非常有用。例如，一個枚舉可以表示一個訂單的不同狀態，並且每個狀態可以關聯一些特定的數據，如訂單的時間戳或詳細信息。 Associated Value（關聯值）的示例：\nenum OrderStatus { case pending case processing(trackingNumber: String) case shipped(deliveryDate: Date) case delivered(signature: String) }\nlet currentOrderStatus = OrderStatus.processing(trackingNumber: \u0026#34;123456\u0026#34;) switch currentOrderStatus { case .pending: print(\u0026#34;訂單等待處理\u0026#34;) case .processing(let trackingNumber): print(\u0026#34;訂單處理中，追蹤編號：\\(trackingNumber)\u0026#34;) case .shipped(let deliveryDate): print(\u0026#34;訂單已出貨，交付日期：\\(deliveryDate)\u0026#34;) case .delivered(let signature): print(\u0026#34;訂單已交付，簽名：\\(signature)\u0026#34;) }在這個示例中，我們定義了一個OrderStatus枚舉，其中每個情況（case）都可以關聯不同的值。例如，processing情況關聯了一個追蹤編號，shipped情況關聯了交付日期，delivered情況關聯了簽名。這使得我們可以表示訂單的不同狀態，同時提供相關的附加資訊。根據枚舉的情況，我們可以檢索和使用相應的關聯值。 總之，雖然這兩者都使用了”關聯”這個詞，但它們在Swift中的使用和目的不同。Associated type主要用於協議，而Associated value主要用於枚舉，以實現更具表達力的數據模型。\niOS associated type 的特性在Python中有嗎?\n在Python中，與Swift中的associated type類似的功能並不是內建的語言特性。Python是一種動態類型語言，它不需要像Swift一樣在協議中指定關聯類型，因為Python變數的類型是在運行時動態確定的。在Python中，你可以在不同的類別或函數中使用不同的類型，而無需事先指定類型。\n在Python中，通常使用duck typing的概念，即”如果它走起來像鴨子，叫起來像鴨子，那麼它就是一只鴨子”。這意味著Python更注重對對象的行為和方法的詢問，而不是對其具體類型的關注。因此，Python不需要像Swift中的associated type那樣的靜態類型確定。\n然而，如果你需要在Python中實現類似於associated type的功能，你可以使用泛型（Generics）或是多態性（Polymorphism）的概念，根據不同的情況和需求來處理不同的類型。 Python支持這些概念，以實現通用性和彈性。但是，這些方法不如Swift中的associated type直接和靜態，而是更加動態和靈活。\n總之，Python和Swift在語言特性和設計上有很大的區別，因此相似的功能在這兩種語言中可能需要不同的處理方式。\n","permalink":"https://peter-notes.com/posts/ios-associated-type-vs-value/","summary":"\u003cp\u003e它們在使用和用途上有很大的區別。讓我們來解釋這兩者之間的差異：\u003c/p\u003e\n\u003ch3 id=\"ios中的associated-type和associated-value是不同的概念\"\u003eiOS中的associated type和associated value是不同的概念\u003c/h3\u003e\n\u003cp\u003e它們在使用和用途上有很大的區別。讓我們來解釋這兩者之間的差異：\u003c/p\u003e\n\u003cp\u003e是的，iOS中的associated type和associated value是不同的概念，它們在使用和用途上有很大的區別。讓我們來解釋這兩者之間的差異：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003eAssociated Type（關聯類型）：\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003eAssociated type是Swift中協議（protocols）的一個特性。\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e關聯類型允許在定義協議時指定一個或多個類型，但不需要提供具體的實現。\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e具體遵循（conforming）該協議的類別或結構體需要提供關聯類型的具體實現。\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e關聯類型使協議更具通用性，因為它可以適應不同的類型。\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eprotocol OrderQueue {\nassociatedtype Order // 定義一個關聯類型\nmutating func enqueue(_ order: Order)\nmutating func dequeue() -\u0026gt; Order?\n}\u003c/p\u003e\n\u003cp\u003estruct OrderQueueImplementation: OrderQueue {\n// 實現OrderQueue協議，指定關聯類型為OrderStatus\ntypealias Order = OrderStatus\nprivate var orders: [OrderStatus] = []\u003c/p\u003e\n\u003cpre\u003e\u003ccode\u003emutating func enqueue(_ order: OrderStatus) {\n    orders.append(order)\n}\n\nmutating func dequeue() -\u0026gt; OrderStatus? {\n    return orders.isEmpty ? nil : orders.removeFirst()\n}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e}\u003c/p\u003e","title":"iOS中的associated type和associated value是不同的概念"},{"content":"在開發過程中，我們常需要快速找出「哪個檔案用到了這個變數？」或是「那個設定檔到底放在哪裡？」。 本文整理了 Linux/Mac 終端機中最常用的搜尋指令：grep、ag 和 find。\n1. 搜尋檔案內容 (grep) grep 是系統內建最強大的文字搜尋工具。\n常用指令 # 在目前目錄 (./) 遞歸搜尋 \u0026#34;R2\u0026#34; grep -rwn --color=auto ./ -e \u0026#39;R2\u0026#39; 參數詳解 -r (recursive)：遞歸搜尋，包含所有子目錄。 -w (word)：僅匹配「完整單字」，避免匹配到部分字串（例如搜尋 is 不會匹配到 this）。 -n (line number)：顯示行號。 -e (pattern)：指定搜尋的關鍵字。 --color=auto：將關鍵字高亮顯示。 顯示前後文 (Context) 有時候只看一行不夠，我們需要看前後幾行程式碼：\n# -C (Context): 顯示前後各 1 行 grep -rwn -C 1 ./ -e \u0026#39;R2\u0026#39; # -A (After): 顯示後 1 行 # -B (Before): 顯示前 1 行 2. 更快的選擇：The Silver Searcher (ag) grep 雖然強大，但速度較慢且指令較長。ag 是一個專為程式碼搜尋設計的工具，速度極快且預設忽略 .gitignore 的檔案。 (需額外安裝：brew install the_silver_searcher)\n常用指令 # 語法超簡單：ag \u0026#34;關鍵字\u0026#34; [路徑] ag \u0026#34;R2\u0026#34; ./ 顯示前後文 # 顯示前後 1 行 ag -C 1 \u0026#34;R2\u0026#34; ./ 優點：\n速度比 grep 快非常多。 輸出結果自帶顏色和行號，閱讀性佳。 指令簡短。 3. 搜尋檔案名稱 (find) 如果你不是要找「內容」，而是要找「檔案在哪裡」，請用 find。\n常用指令 # 搜尋目前目錄下，檔名為 \u0026#34;config.json\u0026#34; 的檔案 find ./ -name \u0026#34;config.json\u0026#34; 使用萬用字元 (*) # 搜尋以 \u0026#34;測試\u0026#34; 開頭的檔案 find ./ -name \u0026#34;測試*\u0026#34; # 搜尋包含 \u0026#34;Lib\u0026#34; 的檔案 find ./ -name \u0026#34;*Lib*\u0026#34; 限制搜尋深度 # 只搜尋第一層目錄，不進入子資料夾 find ./ -maxdepth 1 -name \u0026#34;*vscode*\u0026#34; 總結：該用哪一個？ 需求 推薦工具 指令範例 搜尋程式碼內容 ag (首選) ag \u0026quot;User\u0026quot; . 沒裝 ag 時搜尋內容 grep grep -rwn . -e \u0026quot;User\u0026quot; 搜尋檔案位置 find find . -name \u0026quot;*.json\u0026quot; ag 與 find 的搜尋結果對比\n","permalink":"https://peter-notes.com/posts/search-files-by-name/","summary":"\u003cp\u003e在開發過程中，我們常需要快速找出「哪個檔案用到了這個變數？」或是「那個設定檔到底放在哪裡？」。\n本文整理了 Linux/Mac 終端機中最常用的搜尋指令：\u003ccode\u003egrep\u003c/code\u003e、\u003ccode\u003eag\u003c/code\u003e 和 \u003ccode\u003efind\u003c/code\u003e。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-搜尋檔案內容-grep\"\u003e1. 搜尋檔案內容 (grep)\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003egrep\u003c/code\u003e 是系統內建最強大的文字搜尋工具。\u003c/p\u003e\n\u003ch3 id=\"常用指令\"\u003e常用指令\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 在目前目錄 (./) 遞歸搜尋 \u0026#34;R2\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003egrep -rwn --color\u003cspan class=\"o\"\u003e=\u003c/span\u003eauto ./ -e \u003cspan class=\"s1\"\u003e\u0026#39;R2\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"參數詳解\"\u003e參數詳解\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003e-r\u003c/code\u003e (recursive)：遞歸搜尋，包含所有子目錄。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e-w\u003c/code\u003e (word)：僅匹配「完整單字」，避免匹配到部分字串（例如搜尋 \u003ccode\u003eis\u003c/code\u003e 不會匹配到 \u003ccode\u003ethis\u003c/code\u003e）。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e-n\u003c/code\u003e (line number)：顯示行號。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e-e\u003c/code\u003e (pattern)：指定搜尋的關鍵字。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e--color=auto\u003c/code\u003e：將關鍵字高亮顯示。\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"顯示前後文-context\"\u003e顯示前後文 (Context)\u003c/h3\u003e\n\u003cp\u003e有時候只看一行不夠，我們需要看前後幾行程式碼：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# -C (Context): 顯示前後各 1 行\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003egrep -rwn -C \u003cspan class=\"m\"\u003e1\u003c/span\u003e ./ -e \u003cspan class=\"s1\"\u003e\u0026#39;R2\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# -A (After): 顯示後 1 行\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# -B (Before): 顯示前 1 行\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003chr\u003e\n\u003ch2 id=\"2-更快的選擇the-silver-searcher-ag\"\u003e2. 更快的選擇：The Silver Searcher (ag)\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003egrep\u003c/code\u003e 雖然強大，但速度較慢且指令較長。\u003ccode\u003eag\u003c/code\u003e 是一個專為程式碼搜尋設計的工具，速度極快且預設忽略 \u003ccode\u003e.gitignore\u003c/code\u003e 的檔案。\n\u003cem\u003e(需額外安裝：\u003ccode\u003ebrew install the_silver_searcher\u003c/code\u003e)\u003c/em\u003e\u003c/p\u003e","title":"搜尋目錄下每一個檔案的特定字/檔案名稱"},{"content":"工作需要把整個app包成一個SDK讓另一個app引用，所以要把原本專案的套件也包進去，不然在打包framework的時候，import套件那一行會報錯.\niOS如何把專案打包framwork後也讓原本的pod/套件包入framework ? 工作需要把整個app包成一個SDK讓另一個app引用，所以要把原本專案的套件也包進去，不然在打包framework的時候，import套件那一行會報錯.\n假設你的專案叫”B”，打包的framework取名為BPack，那麽在Podfile的部分要加入framework自己的套件引用，原本的專案裝什麼pod，framework如果有用到該套件也要跟著裝，以SnapKit為例，作法如下：\ntarget \u0026lsquo;B\u0026rsquo; do\nComment the next line if you don\u0026rsquo;t want to use dynamic frameworks use_frameworks! pod \u0026lsquo;SnapKit\u0026rsquo;, \u0026lsquo;~\u0026gt; 5.6.0\u0026rsquo;\nPods for B end\ntarget \u0026lsquo;BPack\u0026rsquo; do\nComment the next line if you don\u0026rsquo;t want to use dynamic frameworks use_frameworks! pod \u0026lsquo;SnapKit\u0026rsquo;, \u0026lsquo;~\u0026gt; 5.6.0\u0026rsquo;\nPods for B Pods for BPack end另外，在Build Setting的部分好像也要做一下設定： 如果你包的是動態庫，那麽在framework target的Build Settings: Linking -\u0026gt; Mach-O Type應該要選擇：Dynamic Libray\nMach-O這部分不太確定有沒有影響，不過我這樣選是可以Run App Successful. 有錯麻煩請指正，感謝！\n關於Mach-O, 問GPT的解答是：\n在Xcode中，Mach-O（Mach Object）是一种可执行文件格式，用于表示 macOS 和 iOS 应用程序的可执行文件、库和框架等。当你设置Xcode项目的” Mach-O Type”时，你实际上是在指定项目的输出文件的Mach-O类型。这个选项会影响你的应用程序或库在编译和链接过程中的最终输出格式。\n以下是常见的Mach-O类型和它们的含义：\nExecutable (可执行文件): 选择这个类型将创建一个独立的可执行文件，用于运行你的应用程序。这是最常见的Mach-O类型，适用于大多数应用程序。 Static Library (静态库): 选择这个类型将创建一个静态库，它包含了一组编译后的代码和符号，可以被其他应用程序或库链接到它们的可执行文件中。静态库在编译时被静态链接到目标程序中，因此目标程序不需要在运行时依赖于外部库。 Dynamic Library (动态库): 选择这个类型将创建一个动态库，也被称为共享库。动态库是一组可重用的代码和符号，可以在运行时被多个可执行文件共享。这减小了可执行文件的大小，但需要确保目标系统上有正确版本的动态库。 Bundle (捆绑包): 这个选项用于创建捆绑包，它是一个包含资源文件和可执行代码的文件夹。捆绑包通常用于插件、扩展或特定类型的应用程序，如macOS应用程序的应用扩展。 Framework (框架): 这个选项用于创建一个包含代码和资源的动态库，通常用于共享代码和资源供多个应用程序使用。框架是一种特殊类型的动态库。 选择适当的Mach-O类型取决于你的项目类型和需求。通常，大多数iOS和macOS应用程序将使用”Executable”类型，而共享代码的项目可能会使用”Static Library”或”Framework”类型，具体取决于你的设计和部署需求。\n","permalink":"https://peter-notes.com/posts/ios-embed-pods-in-framework/","summary":"\u003cp\u003e工作需要把整個app包成一個SDK讓另一個app引用，所以要把原本專案的套件也包進去，不然在打包framework的時候，import套件那一行會報錯.\u003c/p\u003e\n\u003ch3 id=\"ios如何把專案打包framwork後也讓原本的pod套件包入framework\"\u003eiOS如何把專案打包framwork後也讓原本的pod/套件包入framework ?\u003c/h3\u003e\n\u003cp\u003e工作需要把整個app包成一個SDK讓另一個app引用，所以要把原本專案的套件也包進去，不然在打包framework的時候，import套件那一行會報錯.\u003c/p\u003e\n\u003cp\u003e假設你的專案叫”B”，打包的framework取名為BPack，那麽在Podfile的部分要加入framework自己的套件引用，原本的專案裝什麼pod，framework如果有用到該套件也要跟著裝，以SnapKit為例，作法如下：\u003c/p\u003e\n\u003cp\u003etarget \u0026lsquo;B\u0026rsquo; do\u003c/p\u003e\n\u003ch1 id=\"comment-the-next-line-if-you-dont-want-to-use-dynamic-frameworks\"\u003eComment the next line if you don\u0026rsquo;t want to use dynamic frameworks\u003c/h1\u003e\n\u003cp\u003euse_frameworks!\npod \u0026lsquo;SnapKit\u0026rsquo;, \u0026lsquo;~\u0026gt; 5.6.0\u0026rsquo;\u003c/p\u003e\n\u003ch1 id=\"pods-for-b\"\u003ePods for B\u003c/h1\u003e\n\u003cp\u003eend\u003c/p\u003e\n\u003cp\u003etarget \u0026lsquo;BPack\u0026rsquo; do\u003c/p\u003e\n\u003ch1 id=\"comment-the-next-line-if-you-dont-want-to-use-dynamic-frameworks-1\"\u003eComment the next line if you don\u0026rsquo;t want to use dynamic frameworks\u003c/h1\u003e\n\u003cp\u003euse_frameworks!\npod \u0026lsquo;SnapKit\u0026rsquo;, \u0026lsquo;~\u0026gt; 5.6.0\u0026rsquo;\u003c/p\u003e\n\u003ch1 id=\"pods-for-b-1\"\u003ePods for B\u003c/h1\u003e\n\u003ch1 id=\"pods-for-bpack\"\u003ePods for BPack\u003c/h1\u003e\n\u003cp\u003eend另外，在Build Setting的部分好像也要做一下設定：\n如果你包的是動態庫，那麽在framework target的Build Settings:\nLinking -\u0026gt; Mach-O Type應該要選擇：Dynamic Libray\u003c/p\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://cdn-images-1.medium.com/max/800/1*7o3rGav5gTkUHVPAt5swFQ.png\"\u003e\u003c/p\u003e\n\u003cp\u003eMach-O這部分不太確定有沒有影響，不過我這樣選是可以Run App Successful.\n有錯麻煩請指正，感謝！\u003c/p\u003e","title":"iOS如何把專案打包framwork後也讓原本的pod/套件包入framework ?"},{"content":"前言：為什麼 OSI 7層是面試必考題？ 在網路相關的技術面試中，OSI 7層模型幾乎是必考題。不論你是應徵後端工程師、DevOps、網路工程師，還是全端開發者，面試官都可能問：「請說明 OSI 7層模型」、「HTTP 在哪一層？」、「TCP 和 UDP 的差異？」\n理解 OSI 7層模型不僅是為了應付面試，更是因為：\n🔍 除錯網路問題：知道問題出在哪一層，才能對症下藥 🏗️ 設計系統架構：理解各層職責，設計出更好的系統 📡 優化網路效能：知道瓶頸在哪一層，才能有效優化 🛡️ 網路安全防護：不同層有不同的安全考量 本文涵蓋內容 記憶口訣：個人心法，快速記住 7 層順序 每層詳解：功能、協定、實際應用 封包傳輸流程：資料如何在各層之間流動 TCP/IP 對應：實務上常用的 4 層模型 面試常見問題：15+ 個經典面試題與解答 實戰經驗：除錯技巧與常見陷阱 快速記憶：P.D.N.T. S.P.A. 口訣 個人心法 記住每層的首位字母，組成縮寫：P.D.N.T. S.P.A.\n口訣：有三個人（\u0026lsquo;P\u0026rsquo;hy. \u0026lsquo;D\u0026rsquo;a. \u0026lsquo;N\u0026rsquo;et.）去（\u0026lsquo;T.\u0026rsquo;: 想成 To）\u0026lsquo;S.\u0026rsquo; \u0026lsquo;P.\u0026rsquo; \u0026lsquo;A.\u0026rsquo;\nP - Physical (實體層) D - Data Link (資料連結層) N - Network (網路層) T - Transport (傳輸層) --- S - Session (會議層) P - Presentation (表現層) A - Application (應用層) 其他常見記憶法 英文口訣：\n自下而上：Please Do Not Throw Sausage Pizza Away 自上而下：All People Seem To Need Data Processing 中文口訣：\n實資網傳、會表應（實體、資料、網路、傳輸、會議、表現、應用） 為什麼要從下往上記？ OSI 模型的編號是由下而上：\n第 1 層（最底層）：實體層 第 7 層（最頂層）：應用層 在網路除錯時，通常會說「這是第 X 層的問題」，所以記住編號很重要。\nOSI 7層模型架構總覽 關鍵概念：\n上三層（5-7）：應用相關，處理資料格式與使用者互動 中間層（4）：傳輸層，負責端到端的可靠傳輸 下三層（1-3）：網路傳輸，處理實際的資料傳送 第 1 層：實體層 (Physical Layer) 核心功能 實體層負責將數位訊號（0 和 1）轉換為實體訊號，並透過傳輸媒介（電纜、光纖、無線電波）傳送出去。\n主要職責：\n定義硬體規格：電壓、頻率、接頭形狀 訊號轉換：數位訊號 ↔ 類比訊號 傳輸媒介：選擇用電纜還是光纖 位元傳輸：一次傳一個 bit 常見硬體與技術 類型 範例 傳輸方式 有線 雙絞線（CAT5/CAT6） 電訊號 有線 同軸電纜 電訊號 有線 光纖 光訊號 無線 Wi-Fi（2.4GHz/5GHz） 無線電波 無線 藍牙 無線電波 硬體 集線器（Hub） 廣播訊號 硬體 數據機（Modem） 訊號調變 實體層的關鍵問題 為什麼網路線有不同規格（CAT5、CAT6、CAT7）？\n規格 最大速度 最大距離 適用場景 CAT5 100 Mbps 100 公尺 舊設備 CAT5e 1 Gbps 100 公尺 家用網路 CAT6 10 Gbps 55 公尺 企業網路 CAT7 10 Gbps 100 公尺 資料中心 為什麼光纖速度更快？\n電訊號：受電磁干擾，衰減快 光訊號：不受干擾，衰減慢，速度可達 100 Gbps+ 面試常見問題 Q: Hub 和 Switch 的差異？\nHub（集線器）：第 1 層設備，廣播到所有埠，效率低 Switch（交換機）：第 2 層設備，根據 MAC 位址精確轉發 Q: 為什麼無線網路 5GHz 比 2.4GHz 快但穿透力差？\n頻率越高：速度越快，但波長越短，穿透力越差 2.4GHz：穿牆能力強，但速度慢（300-600 Mbps） 5GHz：速度快（1-3 Gbps），但訊號容易被牆壁阻擋 第 2 層：資料連結層 (Data Link Layer) 核心功能 資料連結層負責在**同一個區域網路（LAN）**內，將資料從一個裝置傳送到另一個裝置。它使用 MAC 位址來識別裝置。\n主要職責：\n成幀（Framing）：將位元組裝成 Frame（訊框） MAC 定址：使用實體位址（MAC Address）識別裝置 錯誤偵測：使用 CRC 檢查碼偵測傳輸錯誤 流量控制：避免接收端被淹沒 MAC 位址格式 MAC 位址：48 bits = 6 bytes = 12 個十六進位數字 範例：AA:BB:CC:DD:EE:FF 前 3 bytes：製造商代碼（OUI） 後 3 bytes：裝置序號 常見協定與技術 協定/技術 說明 應用場景 Ethernet 有線區域網路標準 辦公室、家庭網路 Wi-Fi (802.11) 無線區域網路標準 行動裝置連線 PPP 點對點協定 撥接上網 PPPoE 乙太網路上的 PPP ADSL/光纖上網 ARP 位址解析協定 將 IP 轉換為 MAC VLAN 虛擬區域網路 網路隔離 Frame 結構 ARP 位址解析流程 問題：我知道目標 IP 是 192.168.1.100，但不知道它的 MAC 位址，怎麼辦？\n實戰經驗 問題：同事的電腦無法上網，但可以 ping 通 gateway？\n可能原因：\nARP 快取中毒：錯誤的 MAC 位址對應\n# 檢查 ARP 快取 arp -a # 清除 ARP 快取 sudo arp -d \u0026lt;IP\u0026gt; MAC 位址衝突：兩台裝置有相同 MAC（很少見）\nVLAN 設定錯誤：裝置在不同的虛擬網路中\n除錯技巧：使用 Wireshark 抓包\n# 抓取 ARP 封包 sudo tcpdump -i eth0 arp # 檢視 MAC 位址 ip link show 第 3 層：網路層 (Network Layer) 核心功能 網路層負責在不同網路之間傳送資料，使用 IP 位址來識別裝置，並透過**路由（Routing）**找到最佳路徑。\n主要職責：\nIP 定址：使用邏輯位址（IP Address） 路由選擇：找到從來源到目標的最佳路徑 封包轉發：將封包從一個網路傳到另一個網路 分割與重組：將大封包切割成小封包 IP 位址類型 IPv4 位址（32 bits） 格式：192.168.1.100 私有 IP 範圍（不能在公網使用）： - Class A: 10.0.0.0/8 - Class B: 172.16.0.0/12 - Class C: 192.168.0.0/16 IPv6 位址（128 bits） 格式：2001:0db8:85a3:0000:0000:8a2e:0370:7334 簡寫：2001:db8:85a3::8a2e:370:7334 常見協定 協定 全名 功能 IP Internet Protocol 封包傳送 ICMP Internet Control Message Protocol 錯誤回報（ping） IGMP Internet Group Management Protocol 多播管理 IPsec IP Security VPN 加密 路由器的工作原理 子網路遮罩（Subnet Mask） 問題：為什麼要有子網路遮罩？\nIP 位址：192.168.1.100 子網路遮罩：255.255.255.0 (/24) 功能：區分「網路部分」和「主機部分」 - 網路部分：192.168.1 （可以有多個網路） - 主機部分：100 （每個網路可以有多個主機） CIDR 表示法：\n/24 = 255.255.255.0 → 254 個可用 IP /16 = 255.255.0.0 → 65534 個可用 IP /8 = 255.0.0.0 → 16777214 個可用 IP ping 與 traceroute ping：測試連線是否暢通\nping 8.8.8.8 # 輸出 64 bytes from 8.8.8.8: icmp_seq=1 ttl=117 time=10.2 ms traceroute：追蹤封包路徑\ntraceroute google.com # 輸出（每一跳經過的路由器） 1 192.168.1.1 1.234 ms 2 10.0.0.1 5.678 ms 3 172.217.160.78 15.234 ms 面試常見問題 Q: 什麼是 NAT（Network Address Translation）？\n問題：IPv4 位址不夠用（只有 43 億個），但全球有上百億個裝置。\n解決：使用 NAT 讓多個裝置共用一個公網 IP。\nNAT 轉換過程：\n內部：192.168.1.10:5000 → 外部：203.0.113.5:50001 內部：192.168.1.20:5000 → 外部：203.0.113.5:50002 Q: 為什麼需要 IPv6？\nIPv4 位址耗盡：43 億個不夠用 IPv6 位址超級多：340 澗個（3.4×10³⁸） 簡化路由：不需要 NAT，每個裝置都有公網 IP 第 4 層：傳輸層 (Transport Layer) 核心功能 傳輸層負責**端到端（End-to-End）**的資料傳輸，提供可靠或不可靠的傳輸服務。\n主要職責：\n分段與重組：將應用層資料切成小段 連線管理：建立、維持、終止連線 流量控制：避免傳送端過快 錯誤控制：偵測與重傳遺失的封包 埠號（Port）：區分不同應用程式 TCP vs UDP 比較 特性 TCP UDP 連線 需要建立連線（三次握手） 無連線 可靠性 保證送達、順序正確 不保證送達 速度 較慢（因為確認機制） 快速 標頭大小 20 bytes 8 bytes 應用 HTTP, HTTPS, SSH, FTP DNS, DHCP, 直播, 遊戲 TCP 三次握手（Three-Way Handshake） 為什麼需要三次握手？\n確認雙方都能收發：不是兩次就能確認 同步序號：讓雙方知道從哪個序號開始 防止舊連線：避免過期的封包干擾新連線 TCP 連線終止過程（Four-Way Handshake） 為什麼關閉需要四次？\n關閉是單向的：Client 不想傳了，但 Server 可能還有資料要傳 第二次和第三次不能合併：Server 需要時間處理剩餘資料 埠號（Port Number） 常見埠號：\n埠號 協定 服務 20/21 FTP 檔案傳輸 22 SSH 安全遠端登入 23 Telnet 遠端登入（不安全） 25 SMTP 發送郵件 53 DNS 網域名稱解析 80 HTTP 網頁瀏覽 443 HTTPS 安全網頁瀏覽 3306 MySQL 資料庫 5432 PostgreSQL 資料庫 6379 Redis 快取 27017 MongoDB 資料庫 埠號範圍：\n0-1023：知名埠（Well-Known Ports），需要 root 權限 1024-49151：註冊埠（Registered Ports） 49152-65535：動態埠（Dynamic Ports），用於臨時連線 實戰問題 Q: 為什麼 TIME_WAIT 狀態要等 2MSL？\n# 查看 TCP 連線狀態 netstat -an | grep TIME_WAIT 原因：\n確保 ACK 送達：最後一個 ACK 可能遺失，需要時間重傳 避免舊封包干擾：等舊封包都過期才釋放 2MSL = 2 × Maximum Segment Lifetime（2 倍最大分段生命週期）\n預設：60 秒（Linux） Q: 大量 TIME_WAIT 會有什麼問題？\n耗盡 socket 資源：無法建立新連線 解決方法：啟用 SO_REUSEADDR，或調整 tcp_tw_reuse 第 5 層：會議層 (Session Layer) 核心功能 會議層負責建立、管理、終止應用程式之間的會話（Session）。\n主要職責：\n會話建立：協商會話參數 會話維持：保持連線狀態 會話終止：正常或異常結束 同步點（Checkpoint）：大檔案傳輸中斷後可續傳 常見協定與技術 協定/技術 說明 應用場景 NetBIOS 網路基本輸入輸出系統 Windows 檔案分享 RPC 遠端程序呼叫 分散式系統 SQL 資料庫會話 資料庫連線 SIP 會話啟動協定 VoIP 電話 會話管理範例 資料庫連線池（Connection Pool）：\n// Node.js 範例 const pool = mysql.createPool({ host: \u0026#39;localhost\u0026#39;, user: \u0026#39;root\u0026#39;, password: \u0026#39;password\u0026#39;, database: \u0026#39;mydb\u0026#39;, connectionLimit: 10 // 最多 10 個同時連線 }); // 使用連線 pool.query(\u0026#39;SELECT * FROM users\u0026#39;, (error, results) =\u0026gt; { // 查詢完畢後，連線自動歸還到池中 }); 為什麼需要連線池？\n建立 TCP 連線很昂貴：三次握手 + TLS 握手 重複利用連線：避免頻繁建立與關閉 限制連線數量：保護資料庫不被打爆 實戰經驗 問題：API 回應時間異常，但資料庫查詢很快？\n可能原因：連線池耗盡\n# 檢查資料庫連線數 SHOW PROCESSLIST; # 檢查連線池使用率 解決：\n增加連線池大小 縮短連線超時時間 檢查是否有連線洩漏（connection leak） 第 6 層：表現層 (Presentation Layer) 核心功能 表現層負責資料的格式轉換、加密解密、壓縮解壓縮，確保不同系統能夠理解彼此的資料。\n主要職責：\n資料格式轉換：ASCII ↔ EBCDIC 資料加密：SSL/TLS 加密 資料壓縮：GZIP, Brotli 字元編碼：UTF-8, UTF-16 常見格式與編碼 類型 範例 說明 文字編碼 ASCII, UTF-8, UTF-16 字元如何儲存 圖片格式 JPEG, PNG, GIF, WebP 圖片如何壓縮 影片格式 H.264, H.265, VP9 影片如何編碼 資料格式 JSON, XML, Protobuf 結構化資料 加密協定 SSL/TLS 加密傳輸 SSL/TLS 加密 HTTP vs HTTPS：\nTLS 握手流程：\nClient Hello：支援的加密演算法 Server Hello：選擇的加密演算法 證書交換：伺服器傳送 SSL 證書 金鑰交換：建立對稱金鑰 開始加密通訊 資料壓縮 GZIP vs Brotli 比較：\n特性 GZIP Brotli 壓縮率 70-80% 75-85% 壓縮速度 快 慢 支援度 所有瀏覽器 現代瀏覽器 適合 動態內容 靜態資源 範例：啟用 GZIP 壓縮\n# Nginx 設定 gzip on; gzip_types text/plain text/css application/json application/javascript; gzip_min_length 1000; 實戰經驗 問題：為什麼網站載入慢，但檔案大小不大？\n可能原因：未啟用壓縮\n# 檢查伺服器是否支援壓縮 curl -H \u0026#34;Accept-Encoding: gzip\u0026#34; -I https://example.com # 看回應標頭是否有 Content-Encoding: gzip 解決：\n啟用 GZIP 或 Brotli 壓縮 圖片使用 WebP 格式 啟用 HTTP/2 或 HTTP/3 第 7 層：應用層 (Application Layer) 核心功能 應用層是最接近使用者的一層，提供各種網路服務給應用程式使用。\n主要職責：\n提供網路服務：HTTP, FTP, SMTP 使用者介面：瀏覽器、郵件客戶端 資料交換格式：JSON, XML API 設計：RESTful, GraphQL 常見協定 協定 全名 埠號 功能 HTTP Hypertext Transfer Protocol 80 網頁瀏覽 HTTPS HTTP Secure 443 加密網頁 FTP File Transfer Protocol 21 檔案傳輸 SMTP Simple Mail Transfer Protocol 25 發送郵件 POP3 Post Office Protocol 3 110 接收郵件 IMAP Internet Message Access Protocol 143 郵件同步 DNS Domain Name System 53 網域解析 SSH Secure Shell 22 遠端登入 Telnet Telnet 23 遠端登入 DHCP Dynamic Host Configuration Protocol 67/68 IP 分配 HTTP 請求與回應 HTTP 請求範例：\nGET /api/users/123 HTTP/1.1 Host: api.example.com User-Agent: Mozilla/5.0 Accept: application/json Authorization: Bearer token123 HTTP 回應範例：\nHTTP/1.1 200 OK Content-Type: application/json Content-Length: 85 { \u0026#34;id\u0026#34;: 123, \u0026#34;name\u0026#34;: \u0026#34;John Doe\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;john@example.com\u0026#34; } DNS 解析流程 問題：輸入 google.com，如何找到伺服器 IP？\nDNS 記錄類型：\n類型 說明 範例 A IPv4 位址 google.com → 142.250.185.78 AAAA IPv6 位址 google.com → 2404:6800:4003::65 CNAME 別名 www.example.com → example.com MX 郵件伺服器 example.com → mail.example.com TXT 文字記錄 驗證網域所有權 RESTful API vs GraphQL 特性 REST GraphQL 端點 多個（/users, /posts） 單一（/graphql） 資料獲取 固定欄位 自訂欄位 過度獲取 常見（拿到不需要的資料） 不會 請求次數 多次（N+1 問題） 單次 學習曲線 低 中 TCP/IP 模型與 OSI 模型對應 實務上的 4 層模型 實際開發中，我們更常使用 TCP/IP 4 層模型：\n為什麼實務上用 TCP/IP？ OSI 太理想化：會議層和表現層在實務中很少獨立存在 TCP/IP 更實用：對應實際的協定實作 歷史原因：TCP/IP 先發展，OSI 是後來的標準 封包傳輸完整流程 從瀏覽器到伺服器 場景：在瀏覽器輸入 https://google.com，按下 Enter\n封裝與解封裝 傳送端（封裝）：\n接收端（解封裝）：\n面試常見問題精選 基礎題（必考） Q1: 請說明 OSI 7 層模型，並舉例說明各層的協定。\n回答框架：\n由下而上說明每一層 每層舉 1-2 個協定範例 說明該層的主要功能 Q2: TCP 和 UDP 有什麼差異？分別適合什麼場景？\n特性 TCP UDP 連線 需要（三次握手） 無需 可靠性 保證送達 不保證 順序 保證順序 不保證 速度 較慢 較快 適合 HTTP, HTTPS, SSH, 檔案傳輸 直播, 遊戲, DNS, VoIP Q3: HTTP 在哪一層？HTTPS 呢？\nHTTP：第 7 層（應用層） HTTPS：第 7 層（應用層）+ 第 6 層（表現層的 TLS 加密） 進階題（考驗深度） Q4: 為什麼 TCP 需要三次握手？兩次不行嗎？\n兩次握手的問題：\n無法確認 Server 能傳送：只確認了單向通訊 舊連線干擾：過期的 SYN 封包可能建立錯誤的連線 序號同步問題：無法確保雙方都知道彼此的初始序號 Q5: 說明一個完整的 HTTP 請求流程，涉及哪些層？\n第 7 層：應用程式發送 HTTP GET 請求 第 6 層：TLS 加密（如果是 HTTPS） 第 4 層：TCP 將資料分段，加上埠號 第 3 層：IP 加上來源/目標 IP 位址 第 2 層：Ethernet 加上 MAC 位址 第 1 層：轉換為電訊號或光訊號傳送 Q6: 什麼是 MTU？為什麼重要？\nMTU (Maximum Transmission Unit)：最大傳輸單元\nEthernet MTU：1500 bytes 問題：如果封包大於 MTU，需要分片（Fragmentation） 影響：分片會降低效能，增加遺失風險 # 查看 MTU ip link show # 設定 MTU ip link set dev eth0 mtu 1500 Q7: 什麼是 MSS？和 MTU 有什麼關係？\nMSS (Maximum Segment Size)：TCP 資料段的最大大小\nMTU = 1500 bytes IP 標頭 = 20 bytes TCP 標頭 = 20 bytes --- MSS = 1500 - 20 - 20 = 1460 bytes 實戰題（考驗經驗） Q8: 網站載入很慢，如何從 OSI 7 層角度排查？\n逐層檢查：\n# 第 1 層：實體層 ifconfig eth0 # 檢查網卡狀態 ethtool eth0 # 檢查網路速度 # 第 2 層：資料連結層 arp -a # 檢查 ARP 快取 # 第 3 層：網路層 ping 8.8.8.8 # 測試連線 traceroute google.com # 追蹤路徑 # 第 4 層：傳輸層 netstat -an | grep ESTABLISHED # 檢查連線數 ss -s # 查看 socket 統計 # 第 7 層：應用層 curl -w \u0026#34;@curl-format.txt\u0026#34; -o /dev/null https://example.com Q9: 大量 TIME_WAIT 連線，如何解決？\n原因：\n短連線太多（每次請求都建立新連線） 連線關閉後需要等待 2MSL 解決：\n啟用連線重用\n# Linux sysctl -w net.ipv4.tcp_tw_reuse=1 使用長連線\n# Nginx 設定 keepalive_timeout 65; keepalive_requests 100; 調整 TIME_WAIT 超時時間\nsysctl -w net.ipv4.tcp_fin_timeout=30 Q10: 如何用 Wireshark 抓取特定 IP 的 HTTP 封包？\n# 過濾條件 ip.addr == 192.168.1.100 \u0026amp;\u0026amp; http # 或 host 192.168.1.100 and port 80 實戰除錯技巧 各層除錯工具 層級 工具 用途 第 1 層 ethtool, ifconfig, ip link 檢查網卡狀態、速度 第 2 層 arp, tcpdump, arping 檢查 MAC 位址、抓包 第 3 層 ping, traceroute, mtr, ip route 測試連線與路由 第 4 層 netstat, ss, telnet, nc 檢查 TCP/UDP 連線 第 7 層 curl, wget, nslookup, dig, nmap 測試應用層協定 全層級（GUI） Wireshark, Charles, Fiddler 視覺化抓包分析 全層級（CLI） tcpdump, tshark, ngrep 命令列抓包 API 測試 Postman, Insomnia, HTTPie REST API 測試 安全測試 Burp Suite, mitmproxy, OWASP ZAP 安全漏洞掃描 常見問題排查 問題 1：無法 ping 通 逐層檢查：\n# 1. 檢查實體層 ifconfig eth0 # 確認網卡是 UP 狀態 # 2. 檢查資料連結層 arp -a # 確認 gateway MAC 位址存在 # 3. 檢查網路層 ip route # 確認有預設路由 ping \u0026lt;gateway_ip\u0026gt; # 測試能否 ping 通 gateway # 4. 檢查防火牆 iptables -L # 確認沒有阻擋 ICMP 問題 2：DNS 解析失敗 # 檢查 DNS 設定 cat /etc/resolv.conf # 測試 DNS 解析 nslookup google.com dig google.com # 測試特定 DNS 伺服器 nslookup google.com 8.8.8.8 問題 3：連線被重置（Connection Reset） 可能原因：\n防火牆阻擋：iptables 或 Security Group 設定錯誤 服務未啟動：目標埠沒有程式監聽 達到連線上限：伺服器連線數已滿 # 檢查埠是否開啟 telnet \u0026lt;host\u0026gt; \u0026lt;port\u0026gt; # 檢查監聽狀態 netstat -tlnp | grep \u0026lt;port\u0026gt; # 檢查防火牆 iptables -L -n | grep \u0026lt;port\u0026gt; 總結與學習建議 記憶重點 從下往上記（第 1 層到第 7 層）：\nPhysical：實體層 - 硬體、訊號 Data Link：資料連結層 - MAC 位址、Switch Network：網路層 - IP 位址、Router Transport：傳輸層 - TCP/UDP、Port Session：會議層 - 連線管理 Presentation：表現層 - 加密、壓縮 Application：應用層 - HTTP、DNS、SMTP 面試準備建議 必須掌握：\n✅ OSI 7 層名稱、順序、主要功能 ✅ TCP vs UDP 差異 ✅ TCP 三次握手、連線終止過程 ✅ 常見協定與埠號（HTTP, HTTPS, SSH, FTP） ✅ IP 位址、MAC 位址、埠號的差異 加分項：\n🌟 實際除錯經驗（用 Wireshark、tcpdump） 🌟 網路效能優化（連線池、壓縮、HTTP/2） 🌟 安全防護（TLS、防火牆、DDoS） 🌟 雲端網路架構（VPC、Load Balancer、CDN） 實戰練習 用 Wireshark 抓取 HTTP 請求：觀察完整的封裝過程 用 tcpdump 抓取 TCP 三次握手：看實際的 SYN、SYN-ACK、ACK traceroute 追蹤路徑：理解封包如何經過多個路由器 搭建簡單的 HTTP 伺服器：理解應用層如何與傳輸層互動 延伸學習 深入研究：\n📚 HTTP/2 與 HTTP/3 的改進 📚 TLS 1.3 握手流程 📚 BGP 路由協定 📚 Kubernetes 網路模型 參考資源 官方文件：\nOSI Model - Wikipedia TCP/IP Protocol Suite HTTP/1.1 RFC 2616 工具文件：\nWireshark User Guide tcpdump Manual curl Manual 學習資源：\nComputer Networking: A Top-Down Approach The TCP/IP Guide High Performance Browser Networking 結語 理解 OSI 7 層模型不只是為了應付面試，更是為了：\n🎯 快速定位問題：知道問題在哪一層，才能有效除錯 🏗️ 設計更好的系統：理解各層職責，設計出更健壯的架構 🚀 優化效能：知道瓶頸在哪，才能對症下藥 🛡️ 保障安全：每一層都有對應的安全威脅與防護措施 記住：OSI 7 層是理論模型，實務上更常用 TCP/IP 4 層模型。 但 OSI 提供了一個清晰的分層架構，讓我們能夠系統性地理解網路運作原理。\n希望這篇完整指南能幫助你：\n快速記住 OSI 7 層（P.D.N.T. S.P.A.） 理解每層的核心功能 回答面試常見問題 應用在實際工作中 準備面試時，記得多練習實際操作（ping、traceroute、Wireshark），這會讓你的回答更有說服力。Good luck! 🚀\n","permalink":"https://peter-notes.com/posts/osi-7-layers-complete-guide/","summary":"\u003ch2 id=\"前言為什麼-osi-7層是面試必考題\"\u003e前言：為什麼 OSI 7層是面試必考題？\u003c/h2\u003e\n\u003cp\u003e在網路相關的技術面試中，\u003cstrong\u003eOSI 7層模型\u003c/strong\u003e幾乎是必考題。不論你是應徵後端工程師、DevOps、網路工程師，還是全端開發者，面試官都可能問：「請說明 OSI 7層模型」、「HTTP 在哪一層？」、「TCP 和 UDP 的差異？」\u003c/p\u003e\n\u003cp\u003e理解 OSI 7層模型不僅是為了應付面試，更是因為：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e🔍 \u003cstrong\u003e除錯網路問題\u003c/strong\u003e：知道問題出在哪一層，才能對症下藥\u003c/li\u003e\n\u003cli\u003e🏗️ \u003cstrong\u003e設計系統架構\u003c/strong\u003e：理解各層職責，設計出更好的系統\u003c/li\u003e\n\u003cli\u003e📡 \u003cstrong\u003e優化網路效能\u003c/strong\u003e：知道瓶頸在哪一層，才能有效優化\u003c/li\u003e\n\u003cli\u003e🛡️ \u003cstrong\u003e網路安全防護\u003c/strong\u003e：不同層有不同的安全考量\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"本文涵蓋內容\"\u003e本文涵蓋內容\u003c/h3\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e記憶口訣\u003c/strong\u003e：個人心法，快速記住 7 層順序\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e每層詳解\u003c/strong\u003e：功能、協定、實際應用\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e封包傳輸流程\u003c/strong\u003e：資料如何在各層之間流動\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTCP/IP 對應\u003c/strong\u003e：實務上常用的 4 層模型\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e面試常見問題\u003c/strong\u003e：15+ 個經典面試題與解答\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e實戰經驗\u003c/strong\u003e：除錯技巧與常見陷阱\u003c/li\u003e\n\u003c/ol\u003e\n\u003chr\u003e\n\u003ch2 id=\"快速記憶pdnt-spa-口訣\"\u003e快速記憶：P.D.N.T. S.P.A. 口訣\u003c/h2\u003e\n\u003ch3 id=\"個人心法\"\u003e個人心法\u003c/h3\u003e\n\u003cp\u003e記住每層的\u003cstrong\u003e首位字母\u003c/strong\u003e，組成縮寫：\u003cstrong\u003eP.D.N.T. S.P.A.\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e口訣：有三個人（\u0026lsquo;P\u0026rsquo;hy. \u0026lsquo;D\u0026rsquo;a. \u0026lsquo;N\u0026rsquo;et.）去（\u0026lsquo;T.\u0026rsquo;: 想成 To）\u0026lsquo;S.\u0026rsquo; \u0026lsquo;P.\u0026rsquo; \u0026lsquo;A.\u0026rsquo;\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eP - Physical       (實體層)\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eD - Data Link      (資料連結層)\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eN - Network        (網路層)\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eT - Transport      (傳輸層)\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e---\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eS - Session        (會議層)\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eP - Presentation   (表現層)\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eA - Application    (應用層)\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"其他常見記憶法\"\u003e其他常見記憶法\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e英文口訣：\u003c/strong\u003e\u003c/p\u003e","title":"面試必考：OSI 7層模型完全指南 - 從記憶口訣到實戰應用"},{"content":"如果 Dock 有開啟自動隱藏的話，我們可以透過終端機指令把動畫效果關掉，讓操作更順暢。除此之外，還有許多不為人知的 Dock 隱藏祕技，這篇文章將一次整理給你！\nMacOS [祕技] Dock 的隱藏與顯示與更多隱藏技巧 桌面與Dock選項裡面可以找到\n1. 加速 Dock 的顯示與隱藏 預設的 Dock 在自動隱藏時會有一個滑入滑出的動畫，如果你希望它反應更快，可以使用以下指令。\n桌面與 Dock 選項裡面可以找到在終端機下指令：\n消除顯示延遲 當你把滑鼠移動到螢幕邊緣時，Dock 通常會等待一小段時間才開始浮現。這個指令可以將延遲設為 0，讓 Dock 隨傳隨到。\ndefaults write com.apple.dock autohide-delay -float 0; killall Dock 加速動畫效果 除了消除延遲，你還可以加速（或移除）動畫本身的滑動時間。\ndefaults write com.apple.dock autohide-time-modifier -int 0; killall Dock 設為 0 表示瞬間顯示，沒有動畫。如果你覺得太快，也可以設為 0.5（預設值約為 1）。\n還原設定 如果想恢復原狀，請執行：\ndefaults delete com.apple.dock autohide-delay defaults delete com.apple.dock autohide-time-modifier killall Dock 2. 在 Dock 加入空白分隔線 (Spacer) 覺得 Dock 上的應用程式太擁擠，想要分門別類嗎？雖然 macOS 不允許直接拖曳空白，但我們可以透過指令加入「隱形的分隔線」。\n加入應用程式區塊的分隔線 執行一次會加入一個空白區塊，你可以拖曳它來調整位置。\ndefaults write com.apple.dock persistent-apps -array-add \u0026#39;{\u0026#34;tile-type\u0026#34;=\u0026#34;spacer-tile\u0026#34;;}\u0026#39;; killall Dock 加入文件/堆疊區塊的分隔線（右側） 如果你想在垃圾桶那邊的區域加分隔線：\ndefaults write com.apple.dock persistent-others -array-add \u0026#39;{\u0026#34;tile-type\u0026#34;=\u0026#34;spacer-tile\u0026#34;;}\u0026#39;; killall Dock 若要把分隔線移除，這就像移除普通 App 一樣，從 Dock 拖出去直到出現「移除」字樣即可。\n3.讓隱藏的應用程式圖示變半透明 當我們使用 Cmd + H 隱藏應用程式時，Dock 上的圖示通常看起來沒什麼變化。這個指令可以讓被隱藏的 App 圖示變為半透明，讓你一眼就能分辨哪些視窗是隱藏的。\ndefaults write com.apple.dock showhidden -bool true; killall Dock 還原指令：\ndefaults write com.apple.dock showhidden -bool false; killall Dock 4. 啟用隱藏的「吸入 (Suck)」縮小特效 macOS 預設只有「神奇（Genie）」和「縮放（Scale）」兩種視窗縮小效果。其實還有一個被隱藏的「吸入（Suck）」效果，看起來就像視窗被吸塵器吸進 Dock 一樣，非常有趣！\ndefaults write com.apple.dock mineffect -string suck; killall Dock 如果你想換回原本的，可以在「系統設定」\u0026gt;「桌面與 Dock」中調整回「神奇效果」。\n5. 單一應用程式模式 (Single App Mode) 這是一個專注模式。開啟後，當你點選 Dock 上的某個 App 時，其他所有的視窗都會自動隱藏。這能讓你一次只專注在一個應用程式上。\ndefaults write com.apple.dock single-app -bool true; killall Dock 還原指令：\ndefaults write com.apple.dock single-app -bool false; killall Dock 6. 在 Dock 增加「最近使用的項目」堆疊 雖然系統設定裡有「在 Dock 中顯示最近使用的應用程式」，但這個指令可以在右側堆疊區增加一個專門顯示「最近開啟的文件、應用程式、伺服器」的 Stack。\ndefaults write com.apple.dock persistent-others -array-add \u0026#39;{\u0026#34;tile-data\u0026#34; = {\u0026#34;list-type\u0026#34; = 1; }; \u0026#34;tile-type\u0026#34; = \u0026#34;recents-tile\u0026#34;; }\u0026#39;; killall Dock 執行後，Dock 右側會多出一個 Stack，你可以對它按右鍵選擇要顯示哪種類型的最近項目。\n終極大絕：重置 Dock 如果你玩壞了，或者想讓 Dock 回到 macOS 剛安裝好的初始狀態，可以使用這個指令：\ndefaults delete com.apple.dock; killall Dock 注意：這會移除你在 Dock 上所有自訂排列的 App 和堆疊，請謹慎使用。\n希望這些小祕技讓你的 Mac 體驗更上一層樓！\n","permalink":"https://peter-notes.com/posts/macos-dock-hide-show/","summary":"\u003cp\u003e如果 Dock 有開啟自動隱藏的話，我們可以透過終端機指令把動畫效果關掉，讓操作更順暢。除此之外，還有許多不為人知的 Dock 隱藏祕技，這篇文章將一次整理給你！\u003c/p\u003e\n\u003ch3 id=\"macos-祕技-dock-的隱藏與顯示與更多隱藏技巧\"\u003eMacOS [祕技] Dock 的隱藏與顯示與更多隱藏技巧\u003c/h3\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"/images/medium/macos%E7%A5%95%E6%8A%80-dock%E7%9A%84%E9%9A%B1%E8%97%8F%E8%88%87%E9%A1%AF%E7%A4%BA/image_8d6e3a8f.png\"\u003e\n桌面與Dock選項裡面可以找到\u003c/p\u003e\n\u003ch2 id=\"1-加速-dock-的顯示與隱藏\"\u003e1. 加速 Dock 的顯示與隱藏\u003c/h2\u003e\n\u003cp\u003e預設的 Dock 在自動隱藏時會有一個滑入滑出的動畫，如果你希望它反應更快，可以使用以下指令。\u003c/p\u003e\n\u003cp\u003e桌面與 Dock 選項裡面可以找到在終端機下指令：\u003c/p\u003e\n\u003ch3 id=\"消除顯示延遲\"\u003e消除顯示延遲\u003c/h3\u003e\n\u003cp\u003e當你把滑鼠移動到螢幕邊緣時，Dock 通常會等待一小段時間才開始浮現。這個指令可以將延遲設為 0，讓 Dock 隨傳隨到。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003edefaults write com.apple.dock autohide-delay -float 0\u003cspan class=\"p\"\u003e;\u003c/span\u003e killall Dock\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"加速動畫效果\"\u003e加速動畫效果\u003c/h3\u003e\n\u003cp\u003e除了消除延遲，你還可以加速（或移除）動畫本身的滑動時間。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003edefaults write com.apple.dock autohide-time-modifier -int 0\u003cspan class=\"p\"\u003e;\u003c/span\u003e killall Dock\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e設為 \u003ccode\u003e0\u003c/code\u003e 表示瞬間顯示，沒有動畫。如果你覺得太快，也可以設為 \u003ccode\u003e0.5\u003c/code\u003e（預設值約為 1）。\u003c/p\u003e\n\u003ch3 id=\"還原設定\"\u003e還原設定\u003c/h3\u003e\n\u003cp\u003e如果想恢復原狀，請執行：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003edefaults delete com.apple.dock autohide-delay\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003edefaults delete com.apple.dock autohide-time-modifier\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ekillall Dock\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003chr\u003e\n\u003ch2 id=\"2-在-dock-加入空白分隔線-spacer\"\u003e2. 在 Dock 加入空白分隔線 (Spacer)\u003c/h2\u003e\n\u003cp\u003e覺得 Dock 上的應用程式太擁擠，想要分門別類嗎？雖然 macOS 不允許直接拖曳空白，但我們可以透過指令加入「隱形的分隔線」。\u003c/p\u003e","title":"MacOS[祕技] Dock的隱藏與顯示"},{"content":"在 iOS 開發過程中，熟練使用 Xcode 快捷鍵是提升效率的關鍵。與其不斷地在滑鼠與鍵盤間切換，不如讓手指留在鍵盤上，像彈鋼琴一樣流暢地寫程式。\n以下是我整理的 Xcode 必備快捷鍵速查表：\n🚀 導航與跳轉 (Navigation) 最常用的功能，讓你快速在檔案與編輯器之間穿梭。\n功能 快捷鍵 說明 快速開啟檔案 Cmd + Shift + O 最強大功能！ 輸入檔名快速跳轉 (Open Quickly) 定位檔案位置 Cmd + Shift + J 在左側 Project Navigator 中選中目前編輯的檔案 跳轉到編輯器 Cmd + J 聚焦到編輯器區域，並可選擇開啟位置 顯示呼叫階層 Cmd + Ctrl + Shift + H Show Call Hierarchy，查看誰呼叫了這個方法 全域搜尋 Cmd + Shift + F Find in Workspace，搜尋整個專案 🖥 視窗與介面 (Interface) 快速開關不需要的面板，爭取最大的程式碼閱讀空間。\n功能 快捷鍵 說明 開/關 導航面板 Cmd + 0 左側 Navigator (Project, Search, Git\u0026hellip;) 開/關 檢查器 Cmd + Option + 0 右側 Inspector (Attributes, Identity\u0026hellip;) 開/關 除錯區 Cmd + Shift + Y 下方 Debug Area 開新分頁 Cmd + T 開啟新的 Tab 開新視窗 Cmd + Option + T 開啟全新的 Xcode 視窗 📝 編輯與除錯 (Editing \u0026amp; Debugging) 寫程式時的得力助手。\n功能 快捷鍵 說明 顯示 Author Cmd + Ctrl + Shift + A 顯示 Git Blame，看這行程式碼是誰寫的 加入/取消 斷點 Cmd + \\ 在目前行切換 Breakpoint 註解程式碼 Cmd + / 快速註解/取消註解選取行 自動縮排 Ctrl + I Re-indent，排版亂掉時的神器 多行游標 Ctrl + Shift + Click 同時編輯多行 (或按住 Option 拖曳滑鼠) 💡 小技巧 Open Quickly (Cmd + Shift + O) 是最值得投資肌肉記憶的快捷鍵。你不僅可以搜尋檔名，還可以搜尋 類別名稱 或 方法名稱。 當你迷失在專案目錄時，按下 Cmd + Shift + J 可以馬上知道你現在在哪個資料夾底下。 熟練這些快捷鍵，每天至少能幫你省下 30 分鐘！\n","permalink":"https://peter-notes.com/posts/xcode-hotkeys/","summary":"\u003cp\u003e在 iOS 開發過程中，熟練使用 Xcode 快捷鍵是提升效率的關鍵。與其不斷地在滑鼠與鍵盤間切換，不如讓手指留在鍵盤上，像彈鋼琴一樣流暢地寫程式。\u003c/p\u003e\n\u003cp\u003e以下是我整理的 Xcode 必備快捷鍵速查表：\u003c/p\u003e\n\u003ch2 id=\"-導航與跳轉-navigation\"\u003e🚀 導航與跳轉 (Navigation)\u003c/h2\u003e\n\u003cp\u003e最常用的功能，讓你快速在檔案與編輯器之間穿梭。\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth style=\"text-align: left\"\u003e功能\u003c/th\u003e\n          \u003cth style=\"text-align: left\"\u003e快捷鍵\u003c/th\u003e\n          \u003cth style=\"text-align: left\"\u003e說明\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e快速開啟檔案\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003ccode\u003eCmd + Shift + O\u003c/code\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e最強大功能！\u003c/strong\u003e 輸入檔名快速跳轉 (Open Quickly)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e定位檔案位置\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003ccode\u003eCmd + Shift + J\u003c/code\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e在左側 Project Navigator 中選中目前編輯的檔案\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e跳轉到編輯器\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003ccode\u003eCmd + J\u003c/code\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e聚焦到編輯器區域，並可選擇開啟位置\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e顯示呼叫階層\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003ccode\u003eCmd + Ctrl + Shift + H\u003c/code\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003eShow Call Hierarchy，查看誰呼叫了這個方法\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e全域搜尋\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003ccode\u003eCmd + Shift + F\u003c/code\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003eFind in Workspace，搜尋整個專案\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch2 id=\"-視窗與介面-interface\"\u003e🖥 視窗與介面 (Interface)\u003c/h2\u003e\n\u003cp\u003e快速開關不需要的面板，爭取最大的程式碼閱讀空間。\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth style=\"text-align: left\"\u003e功能\u003c/th\u003e\n          \u003cth style=\"text-align: left\"\u003e快捷鍵\u003c/th\u003e\n          \u003cth style=\"text-align: left\"\u003e說明\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e開/關 導航面板\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003ccode\u003eCmd + 0\u003c/code\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e左側 Navigator (Project, Search, Git\u0026hellip;)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e開/關 檢查器\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003ccode\u003eCmd + Option + 0\u003c/code\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e右側 Inspector (Attributes, Identity\u0026hellip;)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e開/關 除錯區\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003ccode\u003eCmd + Shift + Y\u003c/code\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e下方 Debug Area\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e開新分頁\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003ccode\u003eCmd + T\u003c/code\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e開啟新的 Tab\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e開新視窗\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003ccode\u003eCmd + Option + T\u003c/code\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e開啟全新的 Xcode 視窗\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch2 id=\"-編輯與除錯-editing--debugging\"\u003e📝 編輯與除錯 (Editing \u0026amp; Debugging)\u003c/h2\u003e\n\u003cp\u003e寫程式時的得力助手。\u003c/p\u003e","title":"Xcode Hotkey"},{"content":"第69行cell.configure一直進不去\nRxSwift -bindViewModel 第69行cell.configure一直進不去\n原因如下:\nrequired init?(coder aDecoder: NSCoder) 是 UIKit 控制器初始化的一部分，尤其是從 Storyboards 或 Xibs 中加載時。如果你正在嘗試從 Storyboard 或 Xib 中加載你的 ZooViewController，那麼就需要實現這個初始化方法。在你的程式碼中，\nrequired init?(coder aDecoder: NSCoder) { fatalError(\u0026ldquo;init(coder:) has not been implemented\u0026rdquo;) }你用 fatalError(\u0026quot;init(coder:) has not been implemented\u0026quot;) 拋出了一個執行時期的錯誤。\n如果你確實不需要從 Storyboard 或 Xib 加載，你可以避免使用 ZooViewController 作為 Storyboard 或 Xib 的類別，或者避免透過 Storyboard 或 Xib 實例化 ZooViewController。在這種情況下，你的 init(coder:) 方法將永遠不會被呼叫，因此你的 fatalError 也不會觸發。\n如果你需要從 Storyboard 或 Xib 加載 ZooViewController，那麼你需要提供 init(coder:) 方法的實現。這可能意味著你需要提供一個預設的 ZooViewModel，或者提供一種方式來設定 ZooViewModel，如下所示：\nrequired init?(coder aDecoder: NSCoder) { self.viewModel = ZooViewModel() super.init(coder: aDecoder) }最後從Main.storyboard的viewController冠上我要的ZooViewController, 問題就解決了.\n","permalink":"https://peter-notes.com/posts/rxswift-bind-viewmodel/","summary":"\u003cp\u003e第69行cell.configure一直進不去\u003c/p\u003e\n\u003ch3 id=\"rxswift--bindviewmodel\"\u003eRxSwift -bindViewModel\u003c/h3\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://cdn-images-1.medium.com/max/800/1*DEoNPwsJfbURBXE9lgWiQg.png\"\u003e\u003c/p\u003e\n\u003cp\u003e第69行cell.configure一直進不去\u003c/p\u003e\n\u003cp\u003e原因如下:\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003erequired init?(coder aDecoder: NSCoder)\u003c/code\u003e 是 UIKit 控制器初始化的一部分，尤其是從 Storyboards 或 Xibs 中加載時。如果你正在嘗試從 Storyboard 或 Xib 中加載你的 \u003ccode\u003eZooViewController\u003c/code\u003e，那麼就需要實現這個初始化方法。在你的程式碼中，\u003c/p\u003e\n\u003cp\u003erequired init?(coder aDecoder: NSCoder) {\nfatalError(\u0026ldquo;init(coder:) has not been implemented\u0026rdquo;)\n}你用 \u003ccode\u003efatalError(\u0026quot;init(coder:) has not been implemented\u0026quot;)\u003c/code\u003e 拋出了一個執行時期的錯誤。\u003c/p\u003e\n\u003cp\u003e如果你確實不需要從 Storyboard 或 Xib 加載，你可以避免使用 \u003ccode\u003eZooViewController\u003c/code\u003e 作為 Storyboard 或 Xib 的類別，或者避免透過 Storyboard 或 Xib 實例化 \u003ccode\u003eZooViewController\u003c/code\u003e。在這種情況下，你的 \u003ccode\u003einit(coder:)\u003c/code\u003e 方法將永遠不會被呼叫，因此你的 \u003ccode\u003efatalError\u003c/code\u003e 也不會觸發。\u003c/p\u003e\n\u003cp\u003e如果你需要從 Storyboard 或 Xib 加載 \u003ccode\u003eZooViewController\u003c/code\u003e，那麼你需要提供 \u003ccode\u003einit(coder:)\u003c/code\u003e 方法的實現。這可能意味著你需要提供一個預設的 \u003ccode\u003eZooViewModel\u003c/code\u003e，或者提供一種方式來設定 \u003ccode\u003eZooViewModel\u003c/code\u003e，如下所示：\u003c/p\u003e\n\u003cp\u003erequired init?(coder aDecoder: NSCoder) {\nself.viewModel = ZooViewModel()\nsuper.init(coder: aDecoder)\n}最後從Main.storyboard的viewController冠上我要的\u003ccode\u003eZooViewController\u003c/code\u003e, 問題就解決了.\u003c/p\u003e","title":"RxSwift -bindViewModel"},{"content":"前言：Flutter 狀態管理的核心挑戰 在 Flutter 開發中，狀態管理是最重要也最複雜的主題之一。當應用程式規模增長，Widget 樹層級加深，如何讓不同層級的 Widget 能夠正確地監聽和響應狀態變化，成為每個 Flutter 開發者必須面對的挑戰。\n選擇錯誤的狀態監聽方式，會導致：\n❌ 不必要的重繪：整個 Widget 樹被重建，效能下降 ❌ 記憶體洩漏：忘記釋放監聽器，導致記憶體持續增長 ❌ 狀態不同步：多個 Widget 顯示不一致的資料 ❌ 程式碼難以維護：狀態邏輯散落各處，難以追蹤 本文涵蓋內容 本文將深入探討 Flutter 四大主流狀態管理方案的監聽機制：\nProvider：Flutter 官方推薦的輕量級狀態管理 Bloc：基於 Stream 的企業級狀態管理 Riverpod：Provider 的改進版本，解決了 Provider 的核心問題 GetX：高效能的響應式狀態管理 我們將從實際問題出發，比較這四種方案的：\n🔍 核心原理：底層如何實現狀態監聽 💻 實戰範例：可執行的完整程式碼 ⚡ 效能表現：重繪範圍、記憶體使用 🎯 適用場景：什麼情況下使用哪種方案 ⚠️ 常見陷阱：實戰中容易踩的坑 Provider 狀態監聽機制 Provider 是 Flutter 官方推薦的狀態管理方案，基於 InheritedWidget 實現。它的核心優勢是簡單易用，適合中小型應用。\nProvider 核心概念 Provider 使用 依賴注入 (Dependency Injection) 和 作用域 (Scope) 的概念來管理狀態：\n關鍵概念：\nProvider 有作用域限制：只有在 Provider 子樹內的 Widget 能存取狀態 三種存取方式：context.watch()、context.read()、context.select() 自動重繪機制：使用 watch 時，狀態改變會自動觸發 Widget 重繪 實戰問題：跨路由讀取 Provider 這是我在開發中遇到的真實問題：\n// ❌ 錯誤做法：在不同路由中讀取 provider class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: ChangeNotifierProvider( create: (_) =\u0026gt; MyAppState(), child: HomePage(), ), ); } } class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { return ElevatedButton( onPressed: () { // 導航到新路由 Navigator.push( context, MaterialPageRoute(builder: (_) =\u0026gt; DetailPage()), ); }, child: Text(\u0026#39;Go to Detail\u0026#39;), ); } } class DetailPage extends StatelessWidget { @override Widget build(BuildContext context) { // ❌ 錯誤：這裡會拋出 ProviderNotFoundException var appState = context.watch\u0026lt;MyAppState\u0026gt;(); return Scaffold( appBar: AppBar(title: Text(appState.title)), ); } } 錯誤訊息：\nError: Could not find the correct Provider\u0026lt;MyAppState\u0026gt; above this DetailPage Widget This happens because you used a BuildContext that does not include the provider of your choice. 為什麼會發生這個錯誤？ Provider 是有作用域的 (scoped)。當你在 home 屬性中插入 Provider 時，只有 home 的子樹能存取這個 Provider。\n問題分析：\n核心原因：\nNavigator.push 創建的新路由是 MaterialApp 的直接子節點 而 Provider 是在 home 屬性內，不是 MaterialApp 的頂層 因此新路由無法向上找到 Provider 解決方案：提升 Provider 作用域 將 Provider 提升到整個應用程式的頂層，確保所有路由都能存取：\n// ✅ 正確做法：在 MaterialApp 之上插入 Provider class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (_) =\u0026gt; MyAppState(), child: MaterialApp( home: HomePage(), ), ); } } class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { // ✅ 正確：可以存取 Provider var appState = context.watch\u0026lt;MyAppState\u0026gt;(); return Scaffold( appBar: AppBar(title: Text(appState.title)), body: ElevatedButton( onPressed: () { Navigator.push( context, MaterialPageRoute(builder: (_) =\u0026gt; DetailPage()), ); }, child: Text(\u0026#39;Go to Detail\u0026#39;), ), ); } } class DetailPage extends StatelessWidget { @override Widget build(BuildContext context) { // ✅ 正確：可以存取 Provider var appState = context.watch\u0026lt;MyAppState\u0026gt;(); return Scaffold( appBar: AppBar(title: Text(appState.title)), body: Text(\u0026#39;Detail: ${appState.data}\u0026#39;), ); } } 改正後的作用域：\nProvider 的三種監聽方式 Provider 提供三種監聽狀態的方法，各有不同的使用場景：\n1. context.watch() - 自動重繪 class CounterWidget extends StatelessWidget { @override Widget build(BuildContext context) { // 監聽狀態，狀態改變時自動重繪整個 Widget var counter = context.watch\u0026lt;CounterState\u0026gt;(); return Text(\u0026#39;Count: ${counter.value}\u0026#39;); } } 特性：\n✅ 狀態改變時自動重繪 ⚠️ 會重繪整個 Widget，可能影響效能 🎯 適用於需要響應狀態變化的 UI 元件 2. context.read() - 單次讀取 class IncrementButton extends StatelessWidget { @override Widget build(BuildContext context) { return ElevatedButton( onPressed: () { // 只讀取一次，不監聽變化 var counter = context.read\u0026lt;CounterState\u0026gt;(); counter.increment(); }, child: Text(\u0026#39;Increment\u0026#39;), ); } } 特性：\n✅ 不會觸發重繪，效能最佳 ❌ 狀態改變時不會更新 🎯 適用於只需要呼叫方法，不需要顯示狀態的場景 3. context.select\u0026lt;T, R\u0026gt;() - 精確監聽 class UsernameWidget extends StatelessWidget { @override Widget build(BuildContext context) { // 只監聽 username 欄位，其他欄位改變時不重繪 var username = context.select\u0026lt;UserState, String\u0026gt;( (state) =\u0026gt; state.username ); return Text(\u0026#39;Username: $username\u0026#39;); } } 特性：\n✅ 只監聽特定欄位，減少不必要的重繪 ✅ 效能優於 watch 🎯 適用於大型狀態物件，只需要監聽部分欄位 Provider 監聽方式比較 Provider 完整範例 import \u0026#39;package:flutter/material.dart\u0026#39;; import \u0026#39;package:provider/provider.dart\u0026#39;; // 1. 定義狀態類別 class CounterState extends ChangeNotifier { int _count = 0; int get count =\u0026gt; _count; void increment() { _count++; notifyListeners(); // 通知所有監聽者 } void decrement() { _count--; notifyListeners(); } } // 2. 在應用程式頂層提供 Provider void main() { runApp( ChangeNotifierProvider( create: (_) =\u0026gt; CounterState(), child: MyApp(), ), ); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: CounterPage(), ); } } // 3. 使用 watch 監聽狀態 class CounterPage extends StatelessWidget { @override Widget build(BuildContext context) { var counter = context.watch\u0026lt;CounterState\u0026gt;(); return Scaffold( appBar: AppBar(title: Text(\u0026#39;Provider Counter\u0026#39;)), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( \u0026#39;Count: ${counter.count}\u0026#39;, style: TextStyle(fontSize: 48), ), SizedBox(height: 20), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ // 使用 read 呼叫方法 ElevatedButton( onPressed: () =\u0026gt; context.read\u0026lt;CounterState\u0026gt;().decrement(), child: Icon(Icons.remove), ), SizedBox(width: 20), ElevatedButton( onPressed: () =\u0026gt; context.read\u0026lt;CounterState\u0026gt;().increment(), child: Icon(Icons.add), ), ], ), ], ), ), ); } } Bloc 狀態監聽機制 Bloc (Business Logic Component) 是一個基於 Stream 的狀態管理方案，由 Google 工程師開發，特別適合大型企業級應用。\nBloc 核心概念 Bloc 將應用程式分為三個層次：\nPresentation Layer（展示層）：UI Widget Business Logic Layer（業務邏輯層）：Bloc Data Layer（資料層）：Repository 核心原則：\n單向資料流：Widget 發送 Event → Bloc 處理 → 發出新 State 不可變狀態：每次狀態改變都創建新的 State 物件 業務邏輯隔離：UI 不包含業務邏輯，全部在 Bloc 中處理 Bloc 的 Stream-based 監聽 Bloc 使用 Dart 的 Stream 來實現狀態監聽：\n// Bloc 內部實作概念 class CounterBloc { // 輸入：Event Stream final _eventController = StreamController\u0026lt;CounterEvent\u0026gt;(); Sink\u0026lt;CounterEvent\u0026gt; get eventSink =\u0026gt; _eventController.sink; // 輸出：State Stream final _stateController = StreamController\u0026lt;CounterState\u0026gt;(); Stream\u0026lt;CounterState\u0026gt; get stateStream =\u0026gt; _stateController.stream; // 監聽 Event，處理後發送新 State CounterBloc() { _eventController.stream.listen((event) { if (event is IncrementEvent) { final newState = CounterState(count: currentState.count + 1); _stateController.add(newState); } }); } } Stream 的優勢：\n✅ 異步處理：天然支援 async/await ✅ 可測試性：Stream 可以輕易 mock ✅ 可追蹤性：所有狀態變化都通過 Stream Bloc 的三種 Widget 監聽方式 1. BlocBuilder - 自動重繪 BlocBuilder\u0026lt;CounterBloc, CounterState\u0026gt;( builder: (context, state) { // 每次 state 改變時都會重建 return Text(\u0026#39;Count: ${state.count}\u0026#39;); }, ) 特性：\n✅ 狀態改變時自動重繪 ⚠️ 會重繪整個 builder 內的 Widget 樹 🎯 適用於需要根據狀態渲染 UI 的場景 2. BlocListener - 執行副作用 BlocListener\u0026lt;CounterBloc, CounterState\u0026gt;( listener: (context, state) { // 不重繪 Widget，只執行副作用 if (state.count \u0026gt;= 10) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(\u0026#39;Count reached 10!\u0026#39;)), ); } }, child: SomeWidget(), ) 特性：\n✅ 不會重繪 UI ✅ 適合執行導航、顯示對話框等副作用 🎯 適用於根據狀態執行一次性操作 3. BlocConsumer - 結合 Builder 和 Listener BlocConsumer\u0026lt;CounterBloc, CounterState\u0026gt;( listener: (context, state) { // 執行副作用 if (state.isError) { showDialog(/* ... */); } }, builder: (context, state) { // 重繪 UI return Text(\u0026#39;Count: ${state.count}\u0026#39;); }, ) 特性：\n✅ 同時支援 UI 更新和副作用 ⚠️ 不要在 listener 和 builder 中做相同的事 🎯 適用於複雜場景 Bloc 監聽方式比較 Bloc 完整範例 import \u0026#39;package:flutter/material.dart\u0026#39;; import \u0026#39;package:flutter_bloc/flutter_bloc.dart\u0026#39;; // 1. 定義 Event abstract class CounterEvent {} class IncrementEvent extends CounterEvent {} class DecrementEvent extends CounterEvent {} // 2. 定義 State class CounterState { final int count; CounterState({required this.count}); } // 3. 定義 Bloc class CounterBloc extends Bloc\u0026lt;CounterEvent, CounterState\u0026gt; { CounterBloc() : super(CounterState(count: 0)) { // 註冊 Event Handler on\u0026lt;IncrementEvent\u0026gt;((event, emit) { emit(CounterState(count: state.count + 1)); }); on\u0026lt;DecrementEvent\u0026gt;((event, emit) { emit(CounterState(count: state.count - 1)); }); } } // 4. 在應用程式頂層提供 Bloc void main() { runApp( BlocProvider( create: (_) =\u0026gt; CounterBloc(), child: MyApp(), ), ); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: CounterPage(), ); } } // 5. 使用 BlocBuilder 監聽狀態 class CounterPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(\u0026#39;Bloc Counter\u0026#39;)), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // BlocBuilder 自動重繪 BlocBuilder\u0026lt;CounterBloc, CounterState\u0026gt;( builder: (context, state) { return Text( \u0026#39;Count: ${state.count}\u0026#39;, style: TextStyle(fontSize: 48), ); }, ), SizedBox(height: 20), // BlocListener 執行副作用 BlocListener\u0026lt;CounterBloc, CounterState\u0026gt;( listener: (context, state) { if (state.count \u0026gt;= 10) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(\u0026#39;達到 10 了！\u0026#39;)), ); } }, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( onPressed: () { context.read\u0026lt;CounterBloc\u0026gt;().add(DecrementEvent()); }, child: Icon(Icons.remove), ), SizedBox(width: 20), ElevatedButton( onPressed: () { context.read\u0026lt;CounterBloc\u0026gt;().add(IncrementEvent()); }, child: Icon(Icons.add), ), ], ), ), ], ), ), ); } } Bloc 非同步處理範例 Bloc 特別適合處理異步操作，例如 API 請求：\n// Event class FetchUserEvent extends UserEvent { final String userId; FetchUserEvent(this.userId); } // State abstract class UserState {} class UserInitial extends UserState {} class UserLoading extends UserState {} class UserLoaded extends UserState { final User user; UserLoaded(this.user); } class UserError extends UserState { final String message; UserError(this.message); } // Bloc class UserBloc extends Bloc\u0026lt;UserEvent, UserState\u0026gt; { final UserRepository repository; UserBloc(this.repository) : super(UserInitial()) { on\u0026lt;FetchUserEvent\u0026gt;((event, emit) async { emit(UserLoading()); try { final user = await repository.fetchUser(event.userId); emit(UserLoaded(user)); } catch (e) { emit(UserError(e.toString())); } }); } } // UI class UserProfile extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder\u0026lt;UserBloc, UserState\u0026gt;( builder: (context, state) { if (state is UserLoading) { return CircularProgressIndicator(); } else if (state is UserLoaded) { return Text(\u0026#39;User: ${state.user.name}\u0026#39;); } else if (state is UserError) { return Text(\u0026#39;Error: ${state.message}\u0026#39;); } else { return Text(\u0026#39;Press button to load user\u0026#39;); } }, ); } } Riverpod 狀態監聽機制 Riverpod 是 Provider 的作者 Remi Rousselet 開發的新一代狀態管理方案，解決了 Provider 的核心問題：依賴 BuildContext。\nRiverpod 的核心改進 Riverpod 的主要優勢：\n✅ 不依賴 BuildContext：可以在任何地方讀取 Provider ✅ 編譯期類型安全：錯誤在編譯時就能發現 ✅ 更好的測試支援：可以輕易 override Provider ✅ 自動處理生命週期：不需要手動 dispose Riverpod 的 Provider 種類 Riverpod 提供多種 Provider，各有不同的用途：\nProvider 類型 用途 範例 Provider 不會改變的值 設定、常數 StateProvider 簡單狀態 計數器、布林值 StateNotifierProvider 複雜狀態 用戶資料、購物車 FutureProvider 異步資料 API 請求 StreamProvider 即時資料流 WebSocket、Firebase Riverpod 的三種監聽方式 1. ref.watch() - 自動重繪 class CounterWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // 監聽狀態，狀態改變時自動重繪 final count = ref.watch(counterProvider); return Text(\u0026#39;Count: $count\u0026#39;); } } 特性：\n✅ 狀態改變時自動重繪 ✅ 自動管理依賴關係 🎯 適用於需要響應狀態變化的 UI 2. ref.read() - 單次讀取 class IncrementButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return ElevatedButton( onPressed: () { // 只讀取一次，不監聽變化 ref.read(counterProvider.notifier).increment(); }, child: Text(\u0026#39;Increment\u0026#39;), ); } } 特性：\n✅ 不會觸發重繪 ✅ 適合事件處理 🎯 適用於呼叫方法，不需要顯示狀態 3. ref.listen() - 執行副作用 class CounterPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // 監聽狀態變化，執行副作用 ref.listen\u0026lt;int\u0026gt;(counterProvider, (previous, next) { if (next \u0026gt;= 10) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(\u0026#39;達到 10 了！\u0026#39;)), ); } }); final count = ref.watch(counterProvider); return Text(\u0026#39;Count: $count\u0026#39;); } } 特性：\n✅ 不會重繪 UI ✅ 可以比較 previous 和 next 🎯 適用於導航、對話框等副作用 Riverpod 監聽方式比較 Riverpod 完整範例 import \u0026#39;package:flutter/material.dart\u0026#39;; import \u0026#39;package:flutter_riverpod/flutter_riverpod.dart\u0026#39;; // 1. 定義 StateNotifier class CounterNotifier extends StateNotifier\u0026lt;int\u0026gt; { CounterNotifier() : super(0); void increment() =\u0026gt; state++; void decrement() =\u0026gt; state--; } // 2. 創建 Provider（全域定義） final counterProvider = StateNotifierProvider\u0026lt;CounterNotifier, int\u0026gt;((ref) { return CounterNotifier(); }); // 3. 使用 ProviderScope 包裹應用程式 void main() { runApp( ProviderScope( child: MyApp(), ), ); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: CounterPage(), ); } } // 4. 使用 ConsumerWidget 監聽狀態 class CounterPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // ref.watch 自動監聽狀態 final count = ref.watch(counterProvider); // ref.listen 執行副作用 ref.listen\u0026lt;int\u0026gt;(counterProvider, (previous, next) { if (next \u0026gt;= 10 \u0026amp;\u0026amp; (previous ?? 0) \u0026lt; 10) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(\u0026#39;達到 10 了！\u0026#39;)), ); } }); return Scaffold( appBar: AppBar(title: Text(\u0026#39;Riverpod Counter\u0026#39;)), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( \u0026#39;Count: $count\u0026#39;, style: TextStyle(fontSize: 48), ), SizedBox(height: 20), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( onPressed: () { // ref.read 呼叫方法 ref.read(counterProvider.notifier).decrement(); }, child: Icon(Icons.remove), ), SizedBox(width: 20), ElevatedButton( onPressed: () { ref.read(counterProvider.notifier).increment(); }, child: Icon(Icons.add), ), ], ), ], ), ), ); } } Riverpod 異步資料載入 Riverpod 的 FutureProvider 和 AsyncValue 讓異步資料處理變得極其簡單：\n// 定義 FutureProvider final userProvider = FutureProvider.autoDispose.family\u0026lt;User, String\u0026gt;((ref, userId) async { final repository = ref.watch(userRepositoryProvider); return await repository.fetchUser(userId); }); // UI class UserProfile extends ConsumerWidget { final String userId; UserProfile({required this.userId}); @override Widget build(BuildContext context, WidgetRef ref) { final asyncUser = ref.watch(userProvider(userId)); // AsyncValue 自動處理 loading、data、error 狀態 return asyncUser.when( loading: () =\u0026gt; CircularProgressIndicator(), error: (err, stack) =\u0026gt; Text(\u0026#39;Error: $err\u0026#39;), data: (user) =\u0026gt; Text(\u0026#39;User: ${user.name}\u0026#39;), ); } } AsyncValue 的優勢：\n✅ 自動處理三種狀態：loading、data、error ✅ 類型安全，不需要手動類型判斷 ✅ 減少 boilerplate 程式碼 GetX 狀態監聽機制 GetX 是一個極簡主義的狀態管理方案，以極低的學習成本和極高的效能著稱。它不僅是狀態管理，還包含路由管理、依賴注入等功能。\nGetX 核心概念 GetX 的核心是 響應式編程 (Reactive Programming)，使用 .obs 讓變數變成可觀察的：\n// 傳統方式 int count = 0; count++; // UI 不會更新 // GetX 響應式 var count = 0.obs; count++; // UI 自動更新 GetX 的三大核心：\n響應式狀態管理：.obs 變數自動通知 UI 更新 簡單狀態管理：GetBuilder 手動控制更新 依賴注入：Get.put() 和 Get.find() 管理依賴 GetX 響應式更新原理 GetX 的兩種監聽方式 1. Obx - 響應式自動更新 class CounterController extends GetxController { var count = 0.obs; void increment() =\u0026gt; count++; void decrement() =\u0026gt; count--; } // UI class CounterPage extends StatelessWidget { final controller = Get.put(CounterController()); @override Widget build(BuildContext context) { return Obx(() =\u0026gt; Text(\u0026#39;Count: ${controller.count}\u0026#39;)); } } 特性：\n✅ 極簡語法，不需要 Builder pattern ✅ 自動追蹤依賴，只重繪 Obx 內的 Widget ✅ 效能極佳，精確更新 🎯 適用於響應式狀態 2. GetBuilder - 手動控制更新 class CounterController extends GetxController { int count = 0; void increment() { count++; update(); // 手動通知更新 } } // UI class CounterPage extends StatelessWidget { final controller = Get.put(CounterController()); @override Widget build(BuildContext context) { return GetBuilder\u0026lt;CounterController\u0026gt;( builder: (controller) =\u0026gt; Text(\u0026#39;Count: ${controller.count}\u0026#39;), ); } } 特性：\n✅ 不使用 Stream，記憶體佔用極低 ✅ 手動控制更新時機 ✅ 適合效能敏感的場景 🎯 適用於大量資料的列表 GetX vs Obx 比較 GetX 完整範例 import \u0026#39;package:flutter/material.dart\u0026#39;; import \u0026#39;package:get/get.dart\u0026#39;; // 1. 定義 Controller class CounterController extends GetxController { // 響應式變數 var count = 0.obs; void increment() =\u0026gt; count++; void decrement() =\u0026gt; count--; // 當 count 達到 10 時執行副作用 @override void onInit() { super.onInit(); ever(count, (value) { if (value \u0026gt;= 10) { Get.snackbar(\u0026#39;提示\u0026#39;, \u0026#39;達到 10 了！\u0026#39;); } }); } } // 2. 初始化 GetX（不需要包裹 Provider） void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return GetMaterialApp( // 使用 GetMaterialApp home: CounterPage(), ); } } // 3. 使用 Obx 監聽狀態 class CounterPage extends StatelessWidget { // 依賴注入 final CounterController controller = Get.put(CounterController()); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(\u0026#39;GetX Counter\u0026#39;)), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Obx 自動監聽 count 變化 Obx(() =\u0026gt; Text( \u0026#39;Count: ${controller.count}\u0026#39;, style: TextStyle(fontSize: 48), )), SizedBox(height: 20), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( onPressed: controller.decrement, child: Icon(Icons.remove), ), SizedBox(width: 20), ElevatedButton( onPressed: controller.increment, child: Icon(Icons.add), ), ], ), ], ), ), ); } } GetX 的響應式 Worker GetX 提供多種 Worker 來監聽響應式變數的變化：\nclass UserController extends GetxController { var username = \u0026#39;\u0026#39;.obs; var isLoggedIn = false.obs; @override void onInit() { super.onInit(); // 1. ever - 每次變化都執行 ever(username, (value) { print(\u0026#39;Username changed to: $value\u0026#39;); }); // 2. once - 只執行一次 once(isLoggedIn, (value) { if (value) { Get.offAll(() =\u0026gt; HomePage()); } }); // 3. debounce - 防抖動（適合搜尋） debounce(username, (value) { searchUser(value); }, time: Duration(milliseconds: 500)); // 4. interval - 固定間隔執行 interval(username, (value) { saveToDatabase(value); }, time: Duration(seconds: 5)); } } 四種方案深度比較 現在我們來全面比較 Provider、Bloc、Riverpod、GetX 這四種狀態管理方案。\n效能比較 方案 記憶體使用 重繪效率 初始化成本 Provider 低 中 低 Bloc 中 高 中 Riverpod 低 高 低 GetX 極低 極高 極低 學習曲線比較 學習難度排名（從易到難）：\n🥇 GetX：極簡語法，幾乎零學習成本 🥈 Provider：官方推薦，文件完整 🥉 Riverpod：Provider 的改進版，需要理解新概念 Bloc：需要理解 Stream、Event、State，學習曲線最陡 程式碼複雜度比較 同樣的計數器功能，四種方案的程式碼量：\n方案 檔案數量 總行數 boilerplate 程度 GetX 1 個檔案 ~40 行 極低 Provider 1 個檔案 ~60 行 低 Riverpod 1 個檔案 ~70 行 中 Bloc 3 個檔案 ~120 行 高 適用場景比較 選擇建議：\n場景 推薦方案 原因 個人專案、快速原型 GetX 開發速度最快，程式碼最簡潔 中小型應用 Provider / Riverpod 官方支援，社群資源豐富 大型企業應用 Bloc 架構清晰，可測試性最高 從 Provider 遷移 Riverpod 概念相似，遷移成本低 需要複雜異步處理 Bloc / Riverpod Stream 和 FutureProvider 支援完善 生態系統比較 方案 社群規模 官方支援 套件數量 文件品質 Provider ⭐⭐⭐⭐⭐ ✅ Flutter 官方 極多 優秀 Bloc ⭐⭐⭐⭐⭐ ✅ 社群官方 多 優秀 Riverpod ⭐⭐⭐⭐ ✅ 作者維護 中等 良好 GetX ⭐⭐⭐⭐⭐ ✅ 社群維護 多 良好 測試支援比較 // Provider 測試 testWidgets(\u0026#39;Provider counter test\u0026#39;, (tester) async { await tester.pumpWidget( ChangeNotifierProvider( create: (_) =\u0026gt; CounterState(), child: MyApp(), ), ); expect(find.text(\u0026#39;0\u0026#39;), findsOneWidget); }); // Bloc 測試（最容易測試） blocTest\u0026lt;CounterBloc, CounterState\u0026gt;( \u0026#39;emits [1] when IncrementEvent is added\u0026#39;, build: () =\u0026gt; CounterBloc(), act: (bloc) =\u0026gt; bloc.add(IncrementEvent()), expect: () =\u0026gt; [CounterState(count: 1)], ); // Riverpod 測試（支援 override） testWidgets(\u0026#39;Riverpod counter test\u0026#39;, (tester) async { await tester.pumpWidget( ProviderScope( overrides: [ counterProvider.overrideWith((ref) =\u0026gt; mockCounter), ], child: MyApp(), ), ); }); // GetX 測試 test(\u0026#39;GetX counter test\u0026#39;, () { final controller = CounterController(); controller.increment(); expect(controller.count.value, 1); }); 測試難易度排名：\n🥇 Bloc：專為測試設計，有 bloc_test 套件 🥈 Riverpod：支援 override，輕鬆 mock 🥉 Provider：可測試，但需要包裹 Widget GetX：單元測試容易，Widget 測試較難 實戰經驗與常見陷阱 Provider 常見陷阱 1. 跨路由存取失敗 問題：\n// ❌ Provider 在 home 內，新路由無法存取 MaterialApp( home: ChangeNotifierProvider( create: (_) =\u0026gt; MyState(), child: HomePage(), ), ) 解決：\n// ✅ Provider 在 MaterialApp 外層 ChangeNotifierProvider( create: (_) =\u0026gt; MyState(), child: MaterialApp( home: HomePage(), ), ) 2. 忘記 notifyListeners 問題：\nclass CounterState extends ChangeNotifier { int count = 0; void increment() { count++; // ❌ 忘記呼叫 notifyListeners，UI 不會更新 } } 解決：\nvoid increment() { count++; notifyListeners(); // ✅ 通知監聽者 } 3. 過度使用 context.watch 問題：\nclass MyWidget extends StatelessWidget { @override Widget build(BuildContext context) { // ❌ 整個 Widget 都會重繪 var state = context.watch\u0026lt;LargeState\u0026gt;(); return Column( children: [ Text(state.title), // 只用到 title ExpensiveWidget(), // 但這個也會重繪 ], ); } } 解決：\n// ✅ 使用 select 只監聽需要的欄位 var title = context.select\u0026lt;LargeState, String\u0026gt;((state) =\u0026gt; state.title); // ✅ 或分割成更小的 Widget class TitleWidget extends StatelessWidget { @override Widget build(BuildContext context) { var title = context.watch\u0026lt;LargeState\u0026gt;().title; return Text(title); } } Bloc 常見陷阱 1. 忘記關閉 Stream 問題：\nclass MyBloc extends Bloc\u0026lt;MyEvent, MyState\u0026gt; { final StreamController _controller = StreamController(); // ❌ 忘記 close，造成記憶體洩漏 } 解決：\nclass MyBloc extends Bloc\u0026lt;MyEvent, MyState\u0026gt; { final StreamController _controller = StreamController(); @override Future\u0026lt;void\u0026gt; close() { _controller.close(); // ✅ 記得關閉 return super.close(); } } 2. 在 Builder 中發送 Event 問題：\nBlocBuilder\u0026lt;CounterBloc, CounterState\u0026gt;( builder: (context, state) { // ❌ 在 builder 中發送 Event 會導致無限循環 context.read\u0026lt;CounterBloc\u0026gt;().add(IncrementEvent()); return Text(\u0026#39;Count: ${state.count}\u0026#39;); }, ) 解決：\n// ✅ 使用 BlocListener 執行副作用 BlocListener\u0026lt;CounterBloc, CounterState\u0026gt;( listener: (context, state) { if (state.shouldIncrement) { context.read\u0026lt;CounterBloc\u0026gt;().add(IncrementEvent()); } }, child: BlocBuilder\u0026lt;CounterBloc, CounterState\u0026gt;( builder: (context, state) { return Text(\u0026#39;Count: ${state.count}\u0026#39;); }, ), ) 3. 過度使用 BlocConsumer 問題：\n// ❌ listener 和 builder 做相同的事 BlocConsumer\u0026lt;UserBloc, UserState\u0026gt;( listener: (context, state) { if (state is UserLoaded) { print(\u0026#39;User loaded\u0026#39;); } }, builder: (context, state) { if (state is UserLoaded) { return Text(state.user.name); } return CircularProgressIndicator(); }, ) 解決：\n// ✅ 明確分工：listener 處理副作用，builder 處理 UI BlocConsumer\u0026lt;UserBloc, UserState\u0026gt;( listener: (context, state) { if (state is UserError) { // 副作用：顯示錯誤對話框 showDialog(/* ... */); } }, builder: (context, state) { // UI 渲染 if (state is UserLoaded) { return Text(state.user.name); } return CircularProgressIndicator(); }, ) Riverpod 常見陷阱 1. 混淆 watch 和 read 問題：\nfinal myProvider = Provider((ref) { // ❌ 在 Provider 建構式中使用 watch final value = ref.watch(otherProvider); // 這會導致 myProvider 在 otherProvider 改變時重建 return MyClass(value); }); 解決：\n// ✅ 根據需求選擇正確的方法 final myProvider = Provider((ref) { // 如果需要響應式依賴，使用 watch final value = ref.watch(otherProvider); // 如果只需要一次性讀取，使用 read final config = ref.read(configProvider); return MyClass(value, config); }); 2. 忘記使用 autoDispose 問題：\n// ❌ 沒有使用 autoDispose，即使不再使用也不會釋放 final userProvider = FutureProvider\u0026lt;User\u0026gt;((ref) async { return await fetchUser(); }); 解決：\n// ✅ 使用 autoDispose 自動清理 final userProvider = FutureProvider.autoDispose\u0026lt;User\u0026gt;((ref) async { return await fetchUser(); }); 3. 錯誤使用 StateProvider 問題：\n// ❌ 複雜狀態使用 StateProvider final userProvider = StateProvider\u0026lt;User\u0026gt;((ref) =\u0026gt; User()); // 修改時很笨拙 ref.read(userProvider.notifier).state = user.copyWith(name: \u0026#39;New Name\u0026#39;); 解決：\n// ✅ 複雜狀態使用 StateNotifierProvider class UserNotifier extends StateNotifier\u0026lt;User\u0026gt; { UserNotifier() : super(User()); void updateName(String name) { state = state.copyWith(name: name); } } final userProvider = StateNotifierProvider\u0026lt;UserNotifier, User\u0026gt;((ref) { return UserNotifier(); }); // 使用時更直觀 ref.read(userProvider.notifier).updateName(\u0026#39;New Name\u0026#39;); GetX 常見陷阱 1. 全域狀態污染 問題：\n// ❌ 使用 Get.put 創建全域單例 class HomePage extends StatelessWidget { final controller = Get.put(HomeController()); // 這個 Controller 會一直存在，即使頁面已經離開 } 解決：\n// ✅ 使用 Get.lazyPut 或 GetView 自動管理生命週期 class HomePage extends GetView\u0026lt;HomeController\u0026gt; { @override Widget build(BuildContext context) { // GetView 會自動查找 Controller return Text(controller.title); } } // 或在路由中註冊 GetPage( name: \u0026#39;/home\u0026#39;, page: () =\u0026gt; HomePage(), binding: BindingsBuilder(() { Get.lazyPut(() =\u0026gt; HomeController()); }), ) 2. 過度使用 Obx 問題：\n// ❌ 包裹太大的 Widget 樹 Obx(() =\u0026gt; Column( children: [ Text(controller.title.value), ExpensiveWidget(), // 不相關的 Widget 也會重繪 AnotherExpensiveWidget(), ], )) 解決：\n// ✅ 只包裹需要響應式更新的最小單位 Column( children: [ Obx(() =\u0026gt; Text(controller.title.value)), // 只包裹需要更新的部分 ExpensiveWidget(), AnotherExpensiveWidget(), ], ) 3. 忘記 .value 問題：\nclass MyController extends GetxController { var count = 0.obs; void printCount() { print(count); // ❌ 這會印出 Rx\u0026lt;int\u0026gt; 物件，不是值 } } 解決：\nvoid printCount() { print(count.value); // ✅ 使用 .value 取得實際值 } 總結與建議 快速選擇指南 根據你的需求，使用以下決策樹選擇最適合的狀態管理方案：\n最終建議 如果你是 Flutter 初學者：\n🎯 從 Provider 開始，學習官方推薦的方式 📚 理解 context.watch vs context.read 的差異 ⚠️ 注意 Provider 的作用域問題 如果你需要快速開發原型：\n🎯 使用 GetX，極簡語法讓你專注在業務邏輯 📦 一個套件包含狀態管理、路由、依賴注入 ⚡ 效能極佳，開發體驗流暢 如果你正在開發中大型應用：\n🎯 選擇 Riverpod，現代化且類型安全 🔒 編譯期錯誤檢查，減少 runtime 錯誤 🧪 優秀的測試支援 如果你的團隊重視架構和可測試性：\n🎯 採用 Bloc，清晰的架構分層 📋 強制業務邏輯與 UI 分離 ✅ 最完善的測試工具鏈 混合使用策略 實際專案中，不同模組可以使用不同的狀態管理方案：\n// 全域狀態：Riverpod（類型安全） final themeProvider = StateProvider\u0026lt;ThemeMode\u0026gt;((ref) =\u0026gt; ThemeMode.light); final authProvider = StateNotifierProvider\u0026lt;AuthNotifier, AuthState\u0026gt;(...); // 頁面狀態：GetX（快速開發） class ShoppingCartController extends GetxController { var items = \u0026lt;Item\u0026gt;[].obs; } // 複雜業務流程：Bloc（清晰架構） class CheckoutBloc extends Bloc\u0026lt;CheckoutEvent, CheckoutState\u0026gt; { // 處理支付流程 } // 簡單 UI 狀態：setState（最簡單） class ExpandableCard extends StatefulWidget { // 展開/收合狀態不需要狀態管理 } 關鍵要點 沒有完美的方案：每種方案都有優缺點，根據需求選擇 不要過度設計：簡單的頁面狀態不需要複雜的狀態管理 保持一致性：團隊內部盡量使用統一的方案 效能優化：使用 select、Obx、BlocBuilder 減少重繪範圍 測試覆蓋：狀態管理是最值得寫測試的部分 學習資源 Provider:\n官方文件 Flutter 官方教學 Bloc:\n官方文件 Bloc 教學影片 Riverpod:\n官方文件 作者部落格 GetX:\n官方文件 GetX 社群 結語 Flutter 的狀態管理方案眾多，每種都有其存在的理由和適用場景。關鍵不是選擇「最好」的方案，而是選擇最適合你的專案和團隊的方案。\n從實戰經驗來看：\n🎯 小型專案：GetX 或 Provider 足夠 🎯 中型專案：Riverpod 是最佳平衡 🎯 大型專案：Bloc 提供最嚴謹的架構 記住：好的狀態管理不是技術堆疊，而是清晰的資料流和可預測的狀態變化。 無論選擇哪種方案，保持程式碼簡潔、測試覆蓋充足、團隊共識一致，才是成功的關鍵。\n希望這篇文章能幫助你理解 Flutter 狀態管理的核心概念，並在實際專案中做出明智的選擇。Happy coding! 🚀\n","permalink":"https://peter-notes.com/posts/flutter-state-listening-complete-guide/","summary":"\u003ch2 id=\"前言flutter-狀態管理的核心挑戰\"\u003e前言：Flutter 狀態管理的核心挑戰\u003c/h2\u003e\n\u003cp\u003e在 Flutter 開發中，\u003cstrong\u003e狀態管理\u003c/strong\u003e是最重要也最複雜的主題之一。當應用程式規模增長，Widget 樹層級加深，如何讓不同層級的 Widget 能夠正確地監聽和響應狀態變化，成為每個 Flutter 開發者必須面對的挑戰。\u003c/p\u003e\n\u003cp\u003e選擇錯誤的狀態監聽方式，會導致：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e❌ \u003cstrong\u003e不必要的重繪\u003c/strong\u003e：整個 Widget 樹被重建，效能下降\u003c/li\u003e\n\u003cli\u003e❌ \u003cstrong\u003e記憶體洩漏\u003c/strong\u003e：忘記釋放監聽器，導致記憶體持續增長\u003c/li\u003e\n\u003cli\u003e❌ \u003cstrong\u003e狀態不同步\u003c/strong\u003e：多個 Widget 顯示不一致的資料\u003c/li\u003e\n\u003cli\u003e❌ \u003cstrong\u003e程式碼難以維護\u003c/strong\u003e：狀態邏輯散落各處，難以追蹤\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"本文涵蓋內容\"\u003e本文涵蓋內容\u003c/h3\u003e\n\u003cp\u003e本文將深入探討 Flutter 四大主流狀態管理方案的監聽機制：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eProvider\u003c/strong\u003e：Flutter 官方推薦的輕量級狀態管理\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eBloc\u003c/strong\u003e：基於 Stream 的企業級狀態管理\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eRiverpod\u003c/strong\u003e：Provider 的改進版本，解決了 Provider 的核心問題\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eGetX\u003c/strong\u003e：高效能的響應式狀態管理\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e我們將從實際問題出發，比較這四種方案的：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e🔍 \u003cstrong\u003e核心原理\u003c/strong\u003e：底層如何實現狀態監聽\u003c/li\u003e\n\u003cli\u003e💻 \u003cstrong\u003e實戰範例\u003c/strong\u003e：可執行的完整程式碼\u003c/li\u003e\n\u003cli\u003e⚡ \u003cstrong\u003e效能表現\u003c/strong\u003e：重繪範圍、記憶體使用\u003c/li\u003e\n\u003cli\u003e🎯 \u003cstrong\u003e適用場景\u003c/strong\u003e：什麼情況下使用哪種方案\u003c/li\u003e\n\u003cli\u003e⚠️ \u003cstrong\u003e常見陷阱\u003c/strong\u003e：實戰中容易踩的坑\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"provider-狀態監聽機制\"\u003eProvider 狀態監聽機制\u003c/h2\u003e\n\u003cp\u003eProvider 是 Flutter 官方推薦的狀態管理方案，基於 \u003ccode\u003eInheritedWidget\u003c/code\u003e 實現。它的核心優勢是\u003cstrong\u003e簡單易用\u003c/strong\u003e，適合中小型應用。\u003c/p\u003e\n\u003ch3 id=\"provider-核心概念\"\u003eProvider 核心概念\u003c/h3\u003e\n\u003cp\u003eProvider 使用 \u003cstrong\u003e依賴注入 (Dependency Injection)\u003c/strong\u003e 和 \u003cstrong\u003e作用域 (Scope)\u003c/strong\u003e 的概念來管理狀態：\u003c/p\u003e\n\u003cdiv class=\"diagram\"\u003e\n  \u003cimg src=\"/diagrams/2023-07-13-flutter-狀態監聽問題-diagram-a6cf7af3.svg\" alt=\"Mermaid Diagram\" loading=\"lazy\"\u003e\n\u003c/div\u003e\n\u003cp\u003e\u003cstrong\u003e關鍵概念：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eProvider 有作用域限制\u003c/strong\u003e：只有在 Provider 子樹內的 Widget 能存取狀態\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e三種存取方式\u003c/strong\u003e：\u003ccode\u003econtext.watch()\u003c/code\u003e、\u003ccode\u003econtext.read()\u003c/code\u003e、\u003ccode\u003econtext.select()\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e自動重繪機制\u003c/strong\u003e：使用 \u003ccode\u003ewatch\u003c/code\u003e 時，狀態改變會自動觸發 Widget 重繪\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"實戰問題跨路由讀取-provider\"\u003e實戰問題：跨路由讀取 Provider\u003c/h3\u003e\n\u003cp\u003e這是我在開發中遇到的真實問題：\u003c/p\u003e","title":"Flutter 狀態監聽完全指南：Provider、Bloc、Riverpod、GetX 深度比較"},{"content":"","permalink":"https://peter-notes.com/popular/","summary":"","title":"Popular Articles"}]