分類: OMS

Multi-channel OMS system implementation series

  • 多通路電商 OMS 系統實戰:系列導讀

    2026 年:AI 衝擊下的軟體產業

    這是一個特殊的時代。

    SaaS 股票從 2021 年的高點崩跌 70-80%。曾經被視為「永遠成長」的軟體訂閱模式,現在面臨嚴峻的質疑。AI 能夠寫程式碼、能夠自動化客服、能夠生成內容——很多人問:軟體工程師還有未來嗎?

    現實的聲音:

    • 「AI 可以寫 80% 的 CRUD,還需要工程師嗎?」
    • 「Copilot 已經能自動補全程式碼了」
    • 「低代碼平台會取代傳統開發」
    • 「SaaS 已死,ARR 不再性感」

    在這個背景下,為什麼我還要寫這系列文章?為什麼 OMS 這種「傳統」的企業系統還有存在價值?


    AI 無法取代的部分

    讓我們誠實面對:AI 確實改變了很多事。但有些東西,AI 要做到需要企業級的整合架構

    AI 能做的 AI 要做到這些,需要什麼
    生成 CRUD 程式碼 整合架構讓 AI 知道該改哪裡
    寫單元測試 系統設計讓 AI 理解業務邏輯
    補全程式碼片段 標準化介面讓 AI 能套用模式
    呼叫 API OMS 提供統一的 API 讓 AI 操作
    分析數據 系統整合好的數據讓 AI 分析
    關鍵洞察:AI Agent、MCP 等技術正在讓 AI 能操作系統。但 AI 要發揮作用,需要標準化的底層架構。OMS 不是被 AI 取代的對象,而是讓 AI 能發揮的基礎設施

    SaaS 崩跌教會我們什麼?

    2021 年,SaaS 公司用 30-50 倍 ARR 估值。2024 年,同樣的公司只剩 5-8 倍。這不是 SaaS 模式有問題,而是市場重新認識了什麼是真正的價值

    泡沫時期的迷思 現實
    用戶增長就是一切 能留住用戶、能收到錢才是
    燒錢換市佔 正向現金流才能活下去
    功能越多越好 解決核心痛點比較重要
    技術債以後再還 技術債會讓你跑不動
    OMS 系統的價值:它不是「酷炫的新創產品」,而是「讓電商能正常運作的基礎設施」。每天處理訂單、同步庫存、產生出貨單——這些boring but essential的事情,才是真正的商業價值。

    為什麼 OMS 在 2026 年仍然重要?

    1. 電商只會更複雜,不會更簡單

    2020 年:蝦皮、Momo、PChome
    2024 年:加上 TikTok Shop、Shopee Food、跨境電商
    2026 年:更多新平台、更多整合需求

    平台越多,整合的需求越大。AI 可以幫你寫對接程式碼,但決定該怎麼對接、如何處理例外,還是需要人。

    2. 「無聊」的系統反而更持久

    一個觀察:最持久的軟體系統,往往是那些「不性感」的:

    • 銀行核心系統(COBOL 跑了 50 年)
    • ERP 系統(SAP 依然是企業標配)
    • 訂單管理系統(每個電商都需要)

    這些系統不會上 TechCrunch 頭條,但它們每天都在創造價值

    3. AI 是工具,不是替代品

    我現在寫程式碼,確實會用 AI 輔助。但 AI 給我的是草稿,我需要:

    • 理解業務需求,決定架構方向
    • 審核 AI 生成的程式碼是否符合系統設計
    • 處理 AI 不知道的「公司內部潛規則」
    • 跟平台 API 變更搏鬥(AI 不知道蝦皮上週改了什麼)

    這系列文章的價值

    這不是一篇「如何用某個框架」的教學。這是真實企業系統的設計思考

    你能學到的 為什麼重要
    工廠模式整合多平台 AI 時代更需要好的架構設計
    事件驅動處理高併發 擴展性是系統長期價值的關鍵
    多租戶認證 SaaS 模式的技術基礎
    分散式系統的觀測性 系統越複雜,可觀測性越重要
    Kubernetes 部署 雲原生是現在的標配
    核心觀點:技術會變,但解決問題的思維方式不會變。理解為什麼這樣設計、權衡了什麼、踩過什麼坑——這些經驗在 AI 時代反而更珍貴。

    回到最初的問題:為什麼需要 OMS?

    想像你是一個電商賣家,同時在蝦皮、Momo、Yahoo、PChome、樂天等 17 個平台上銷售商品。

    每天的噩夢:

    • 登入 17 個後台看訂單
    • 在 17 個平台更新庫存
    • 處理 17 種不同格式的出貨單
    • 應付 17 種不同的 API 規格變更

    AI 能幫你自動回覆客服訊息,但它不會幫你把蝦皮的訂單自動同步到你的倉儲系統。這種系統整合的工作,需要專門設計的軟體。


    商業價值(數字說話)

    70%
    人力成本降低
    10x
    處理速度提升
    99%
    庫存準確率
    80%
    擴展成本降低

    這些數字怎麼來的?讓我拆解給你看。

    計算基礎:一個中型電商的假設

    參數 數值 說明
    日均訂單量 500 單 中型電商規模
    銷售平台數 5 個 蝦皮、Momo、Yahoo、PChome、官網
    SKU 數量 2,000 個 中型商品數
    客服/營運人員月薪 35,000 元 含勞健保約 42,000

    1. 人力成本降低 70%:怎麼算的?

    沒有 OMS 的人力配置:

    工作內容 每日耗時 需要人力
    登入 5 個平台抓訂單 2 小時 × 3 次/天 1 人
    手動輸入訂單到 ERP 500 單 × 2 分鐘 2 人
    更新 5 個平台庫存 2,000 SKU × 5 平台 1 人
    產生出貨單(各平台格式) 500 單 × 3 分鐘 1 人
    合計 5 人

    有 OMS 的人力配置:

    工作內容 每日耗時 需要人力
    訂單自動同步 0(系統自動) 0
    審核異常訂單 約 5% 需人工,50 單 × 2 分鐘 0.2 人
    庫存自動同步 0(系統自動) 0
    批次產生出貨單 點一下,500 單 × 0.1 分鐘 0.1 人
    系統維運/例外處理 每天約 2-3 小時 0.5 人
    客服(不變) 需處理客戶問題 0.7 人
    合計 1.5 人
    計算:(5 – 1.5) / 5 = 70% 人力減少

    年省成本:3.5 人 × 42,000 元 × 12 月 = 176 萬元/年


    2. 處理速度提升 10 倍:怎麼算的?

    沒有 OMS(手動流程):

    步驟 耗時 說明
    平台抓單 等待下次人工抓取 平均等 2-4 小時
    人工輸入 ERP 2-5 分鐘/單 容易出錯要重做
    列印出貨單 3-5 分鐘/單 各平台格式不同
    撿貨出貨 實際作業時間 約 30 分鐘
    總計 4-8 小時 從下單到出貨

    有 OMS(自動流程):

    步驟 耗時 說明
    訂單自動同步 5 分鐘內 Webhook 或輪詢
    自動寫入系統 即時 無需人工
    批次產生出貨單 1 分鐘/批 統一格式
    撿貨出貨 實際作業時間 約 20 分鐘(有優化動線)
    總計 25-35 分鐘 從下單到出貨
    計算:6 小時 / 30 分鐘 = 12 倍(取保守值 10 倍)

    3. 庫存準確率 85% → 99%:怎麼算的?

    沒有 OMS 的庫存問題:

    問題來源 發生頻率 影響
    平台 A 賣掉,平台 B 還沒更新 每天 5-10 次 超賣
    人工輸入錯誤 約 2% 錯誤率 庫存不準
    更新延遲(人力不足) 下班後無人更新 隔夜超賣
    多平台庫存加總錯誤 每週發生 帳實不符

    估算:2,000 SKU,每天約 300 個有異動,其中 15% 會有某種不準確 = 約 85% 準確率

    有 OMS 的庫存管理:

    • 單一庫存來源,自動同步到所有平台
    • 賣出立即扣庫存,無延遲
    • 異常(負庫存)自動警示
    • 剩餘的 1% 誤差來自:實體盤點差異、退貨處理時間差
    結果:系統化管理後,準確率提升到 99%

    減少損失:假設超賣一次平均損失 500 元(退款 + 負評 + 平台罰款),每天減少 5 次 = 每月省 75,000 元


    4. 擴展成本降低 80%:怎麼算的?

    沒有 OMS,新增一個平台:

    工作項目 耗時 成本
    研究平台 API 文件 1-2 週 工程師人力
    開發訂單同步 2-3 週 工程師人力
    開發庫存同步 1-2 週 工程師人力
    開發出貨單格式 1 週 工程師人力
    測試、修 bug、上線 2-4 週 工程師 + QA
    營運人員培訓 1 週 培訓成本
    總計 8-12 週 約 50-80 萬

    有 OMS,新增一個平台:

    工作項目 耗時 成本
    研究平台 API 文件 3-5 天 工程師人力
    實作 ChannelAction 介面 1 週 套用現有架構
    DTO 轉換器 3-5 天 格式對照
    測試、上線 1 週 已有測試框架
    營運人員培訓 1 天 介面相同,只是多一個選項
    總計 2-3 週 約 10-15 萬
    計算:(60萬 – 12萬) / 60萬 = 80% 成本降低

    時間:從 2-3 個月縮短到 2-3 週


    ROI 總結

    效益項目 年度價值 計算方式
    人力成本節省 176 萬 3.5 人 × 42K × 12 月
    超賣損失減少 90 萬 75K × 12 月
    新通路快速上線(假設年增 2 個) 96 萬 48 萬 × 2 個通路
    出貨效率提升(減少延遲出貨罰款) 36 萬 估算
    年度總效益 約 400 萬
    注意:以上數字基於「日均 500 單、5 個平台」的中型電商假設。實際效益會因公司規模、產業特性而異。但數量級是對的——OMS 的 ROI 通常在 1-2 年內就能回本。

    投資成本與風險揭露

    講完效益,也要誠實談成本和風險。

    導入成本估算

    項目 自建開發 SaaS 方案
    初期建置 300-800 萬 0-50 萬
    月費/維護 5-15 萬(團隊) 2-10 萬(訂閱)
    導入期 6-12 個月 1-3 個月
    客製化程度 完全可控 受限於平台
    回本期 1-2 年 3-6 個月

    常見失敗風險

    導入 OMS 可能失敗的原因:

    • 需求不明確:沒釐清要解決什麼問題就開始開發
    • 低估平台複雜度:每個電商平台的 API 都有坑
    • 團隊能力不足:沒有足夠的開發和維運資源
    • 變更管理失敗:使用者不願意改變工作流程
    • 過度客製化:追求完美導致永遠做不完

    市場方案比較

    自建 OMS 不是唯一選擇。以下是常見方案的比較:

    方案類型 代表產品 適合誰 優點 缺點
    SaaS OMS 91APP、CYBERBIZ、EasyStore 中小型電商 快速上線、低成本、有人維護 客製化受限、數據在別人手上
    平台原生工具 蝦皮賣家中心、Momo 後台 單平台賣家 免費、原生整合 只能管單一平台、功能受限
    ERP 模組 SAP、Oracle、鼎新 大型企業 財務整合、企業級穩定 重、貴、慢、電商功能弱
    自建系統 本文討論的方式 有 IT 團隊的中大型電商 完全客製、數據自主、可深度整合 開發成本高、需要團隊維護
    選擇建議:

    • 日均 < 100 單、1-2 平台:用平台原生工具就好
    • 日均 100-500 單、3-5 平台:考慮 SaaS OMS
    • 日均 > 500 單、5+ 平台、有特殊需求:考慮自建或深度客製

    實際案例參考

    以下是幾個匿名化的導入案例:

    案例 A:服飾品牌(成功)

    規模 日均 800 單、7 個平台
    原本痛點 6 人團隊處理訂單、每天加班、超賣頻繁
    導入方式 自建 OMS,開發期 8 個月
    結果 縮減到 2 人、超賣降到每月 1-2 次、14 個月回本
    關鍵成功因素 老闆全力支持、IT 主管有電商經驗

    案例 B:3C 經銷商(部分成功)

    規模 日均 300 單、4 個平台
    原本痛點 ERP 和電商平台脫鉤、手動對帳
    導入方式 先用 SaaS,後來部分自建
    結果 訂單處理自動化成功,但庫存同步仍有問題
    教訓 低估了 ERP 整合的複雜度

    案例 C:食品電商(失敗重來)

    規模 日均 200 單、3 個平台
    原本痛點 想提升效率、老闆看到別人有就想要
    導入方式 外包開發
    結果 做了 6 個月、花了 150 萬、最後沒上線
    失敗原因 需求一直變、外包商不懂電商、內部沒人能接手
    案例啟示:導入 OMS 不是花錢就會成功。需要明確的需求有能力的團隊管理層支持三者缺一不可。

    OMS 可能不適合你

    誠實說,不是每家公司都需要 OMS:

    如果你符合以下情況,可能不需要(或還不需要)OMS:

    • 只在 1-2 個平台銷售:平台原生工具夠用
    • 日均訂單 < 50 單:人工處理得過來,ROI 不划算
    • 沒有 IT 資源:系統會變成另一個維護包袱
    • 業務模式還在摸索:需求不穩定,系統做了也會一直改
    • 現金流緊張:有更急迫的事情要處理

    反思:如果人工作業者也用 AI 呢?

    這是 2026 年必須面對的問題:如果營運人員也會用 ChatGPT、Copilot 等 AI 工具,還需要 OMS 嗎?

    AI 能幫助人工作業的部分

    工作內容 AI 能幫的 效率提升
    填寫表格 自動補全、格式轉換 2x
    回覆客戶訊息 生成範本回覆 3x
    整理 Excel 資料 公式生成、格式處理 2x
    查詢訂單狀態 整理多平台資訊(需人工複製貼上) 1.5x
    產生報表 數據分析、圖表建議 2x

    AI 無法幫助的部分

    關鍵限制:AI 是「對話工具」,不是「系統整合工具」

    • 無法登入平台後台:AI 不能幫你登入蝦皮抓訂單
    • 無法即時同步:你睡覺時,AI 也不會幫你更新庫存
    • 無法批次操作:AI 一次處理一個問題,不能同時處理 500 張訂單
    • 無法跨系統傳遞:AI 不能自動把訂單從蝦皮寫進你的 ERP
    • 無法 24/7 運作:沒有人下指令,AI 就不會動

    調整後的人力需求比較

    情境 需要人力 年人事成本
    純人工(無 AI) 5 人 252 萬
    人工 + AI 輔助 3 人(效率提升約 40%) 151 萬
    OMS 系統 1.5 人 76 萬
    OMS + AI 輔助 1 人(例外處理更快) 50 萬

    重新計算 ROI

    比較基準改變:「人工 + AI」vs「OMS」

    效益項目 人工+AI vs 純人工 OMS vs 人工+AI
    人力成本節省 省 101 萬/年 再省 75 萬/年
    超賣損失 無改善(還是會漏) 省 90 萬/年
    處理速度 快 1.5 倍 快 10 倍
    24/7 運作 不可能 可以
    新通路擴展 一樣要重新訓練人 2-3 週上線
    結論:即使人工作業者使用 AI,OMS 仍然每年省下約 200 萬(75 萬人力 + 90 萬超賣損失 + 其他效益)。

    原因:AI 讓「人」更有效率,但 OMS 解決的是「系統整合」問題——這兩者是不同層次的事情。

    真正的比較:「人+AI」vs「系統+AI」

    情境 A:人工 + AI
    ─────────────────────────────────
    人 → ChatGPT 幫忙寫回覆 → 人複製貼上到平台
    人 → 登入蝦皮看訂單 → 人手動輸入 ERP → 人更新其他平台庫存
    人 → AI 幫忙整理 Excel → 人複製貼上到各系統

    瓶頸:每個動作都需要「人」當中介

    情境 B:OMS 系統 + AI
    ─────────────────────────────────
    訂單 → OMS 自動同步 → 自動進 ERP → 自動更新所有平台庫存
    人 → AI 幫忙處理例外 → 在 OMS 一個介面完成
    報表 → 系統自動產生 → AI 幫忙分析

    瓶頸:只有「例外」需要人處理

    思考框架:

    • AI 提升「點」的效率:讓單一任務做得更快
    • OMS 解決「線」的問題:讓流程自動串連
    • 兩者結合才是最佳解:自動化流程 + AI 處理例外

    系統架構全景圖

    這 10 篇技術文章涵蓋了 OMS 系統的各個層面,以下是它們在系統中的位置:

    ┌─────────────────────────────────────────────────────────────────────────┐
    外部平台層
    │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
    │ │ 蝦皮 │ │ Momo │ │ Yahoo │ │ PChome │ │ … │ │
    │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
    │ │ │ │ │ │ │
    │ └───────────┴───────────┴───────────┴───────────┘ │
    │ │ │
    │ ┌──────────▼──────────┐ │
    │ │ [6] HTTP 客戶端 │ ← OkHttp 連線池、重試機制 │
    │ │ [8] JSON 序列化 │ ← 時區處理、格式轉換 │
    │ └──────────┬──────────┘ │
    └───────────────────────────────┼─────────────────────────────────────────┘

    ┌───────────────────────────────▼─────────────────────────────────────────┐
    整合層
    │ │
    │ ┌─────────────────────────────────────────────────────────────────┐ │
    │ │ [1] 工廠模式 + 策略模式 │ │
    │ │ ChannelFactory → ShopeeAction / MomoAction / YahooAction … │ │
    │ └─────────────────────────────────────────────────────────────────┘ │
    │ │
    │ ┌─────────────────────────────────────────────────────────────────┐ │
    │ │ [5] DTO 設計 │ │
    │ │ 外部 DTO ←→ Converter ←→ 內部 DTO │ │
    │ └─────────────────────────────────────────────────────────────────┘ │
    │ │
    └───────────────────────────────┬─────────────────────────────────────────┘

    ┌───────────────────────────────▼─────────────────────────────────────────┐
    核心服務層
    │ │
    │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
    │ │ OrderService │ │InventorySync│ │ ShippingServ │ │
    │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
    │ │ │ │ │
    │ └───────────────────┼───────────────────┘ │
    │ │ │
    │ ┌──────────────────────────▼──────────────────────────────────────┐ │
    │ │ [3] 多租戶認證 │ │
    │ │ Token 驗證 → SecurityContext → 商戶隔離 │ │
    │ └─────────────────────────────────────────────────────────────────┘ │
    │ │
    │ ┌──────────────────────────────────────────────────────────────┐ │
    │ │ [7] PDF 生成 ← 出貨單、撿貨單、對帳單 │ │
    │ └──────────────────────────────────────────────────────────────┘ │
    │ │
    └───────────────────────────────┬─────────────────────────────────────────┘

    ┌───────────────────────────────▼─────────────────────────────────────────┐
    訊息層
    │ │
    │ ┌─────────────────────────────────────────────────────────────────┐ │
    │ │ [2] Kafka 事件驅動 │ │
    │ │ Producer → Topics (per channel) → Consumer Jobs │ │
    │ └─────────────────────────────────────────────────────────────────┘ │
    │ │
    └───────────────────────────────┬─────────────────────────────────────────┘

    ┌───────────────────────────────▼─────────────────────────────────────────┐
    基礎設施層
    │ │
    │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
    │ │ [4] 健康檢查 │ │ [9] 分散式追蹤 │ │ [10] K8s 部署 │ │
    │ │ Actuator │ │ OpenTracing │ │ Helm Charts │ │
    │ │ 自訂 Indicator │ │ Jaeger │ │ ConfigMap │ │
    │ └────────────────┘ └────────────────┘ └────────────────┘ │
    │ │
    └─────────────────────────────────────────────────────────────────────────┘
    閱讀建議:

    • 從頭開始:按順序讀,理解系統如何從外到內設計
    • 解決特定問題:直接跳到對應章節
    • 架構設計師:重點看 [1] 工廠模式、[2] Kafka、[10] K8s
    • 後端工程師:重點看 [3] 認證、[5] DTO、[6] HTTP

    系列文章導覽

    # 主題 解決什麼問題 AI 時代的價值
    1 工廠模式 整合 17 個平台的不同 API 架構設計思維
    2 Kafka 事件驅動 高併發訂單處理 分散式系統設計
    3 多租戶認證 一套系統服務多商戶 SaaS 技術基礎
    4 健康檢查 自動監控系統狀態 可觀測性
    5 DTO 設計 管理數百個資料物件 程式碼組織
    6 HTTP 客戶端 穩定呼叫外部 API 整合實務
    7 PDF 生成 統一出貨標籤格式 API 設計
    8 JSON 序列化 處理不同平台的時區 資料處理
    9 分散式追蹤 追蹤請求在各服務的流向 除錯能力
    10 K8s 部署 管理 17+ 個服務部署 雲原生技能

    適合讀者

    角色 可以學到
    正在焦慮的工程師 AI 時代什麼能力還有價值
    想轉型的技術人 企業系統的實戰經驗
    技術主管 如何設計可擴展的系統
    電商從業者 技術如何解決業務痛點

    關於作者

    Tom|10+ 年軟體工程經驗

    經歷過幾個時代:

    • 2010s:傳統 SI、CRM 系統
    • 2015+:電商爆發、系統整合
    • 2020s:雲原生、微服務
    • 2024+:AI 衝擊、重新定位

    這系列文章來自在精誠開發多通路電商 OMS 系統的實戰經驗。不是教科書理論,是真正上線運營、處理過各種奇怪問題的心得。

    寫這些文章的原因:在 AI 時代,我相信「理解系統為什麼這樣設計」比「會寫程式碼」更有價值。希望這些經驗對你有幫助。


    下一步

    如果你是工程師:

    如果你是技術主管或決策者:

    • 想評估貴公司是否適合導入 OMS?歡迎來信交流
    • 需要技術諮詢或系統規劃?我提供電商系統架構顧問服務
    聯絡方式:


    這是「多通路電商 OMS 系統實戰」系列的導讀篇。點擊上方表格中的連結,深入每個技術主題。

  • K8s 部署實戰:Helm Charts 與服務編排

    商業價值:標準化部署讓「17 個通路獨立升級不互相影響」,確保 導讀篇提到的「系統穩定性」——單一通路故障不會拖垮整個系統。

    為什麼不用其他方案?

    方案 優點 缺點 適用場景
    Helm Charts(本文) 標準化、版本控制、可重用 學習曲線 多服務微服務架構
    純 YAML 簡單直接 重複多、難維護 單一服務
    Kustomize 原生支援 複雜覆寫較難 簡單環境差異
    Docker Compose 開發方便 不適合生產環境 本地開發

    前言:微服務部署的挑戰

    多通路 OMS 系統包含:

    服務類型 數量 說明
    Consumer Job 17+ 個 每個通路一個
    API 服務 10+ 個 訂單、商品、物流等
    Web 前端 3 個 商戶、管理、OpenAPI
    問題:如何有效管理這麼多服務的部署?

    解決方案:Helm Charts

    Chart 目錄結構

    helm/
    └── oms-consumer-shopee/
    ├── Chart.yaml # Chart 基本資訊
    ├── values.yaml # 預設變數
    ├── values-dev.yaml # 開發環境
    ├── values-prod.yaml # 正式環境
    ├── config/ # 應用程式配置
    │ ├── application.yml
    │ └── logback.xml
    └── templates/ # K8s 資源模板
    ├── deployment.yaml
    ├── service.yaml
    └── configmap.yaml

    Deployment 設定

    # templates/deployment.yaml
    apiVersion: apps/v1
    kind: Deployment
    metadata:
    name: {{ .Chart.Name }}
    namespace: oms
    labels:
    app: {{ .Chart.Name }}

    spec:
    replicas: {{ .Values.replicas }}
    selector:
    matchLabels:
    app: {{ .Chart.Name }}

    template:
    metadata:
    annotations:
    # ConfigMap 變更時自動觸發滾動更新
    checksum/config: {{ include (print $.Template.BasePath “/configmap.yaml”) . | sha256sum }}
    # Prometheus 監控
    prometheus.io/scrape: “true”
    prometheus.io/path: /metrics
    prometheus.io/port: “8080”

    labels:
    app: {{ .Chart.Name }}
    version: {{ .Values.image.version }}

    spec:
    containers:
    – name: {{ .Chart.Name }}
    image: “{{ .Values.image.repository }}:{{ .Values.image.version }}”
    imagePullPolicy: {{ .Values.image.pullPolicy }}

    # 資源限制
    resources:
    requests:
    cpu: {{ .Values.resources.requests.cpu }}
    memory: {{ .Values.resources.requests.memory }}
    limits:
    cpu: {{ .Values.resources.limits.cpu }}
    memory: {{ .Values.resources.limits.memory }}

    # 健康檢查
    livenessProbe:
    httpGet:
    path: /health/liveness
    port: 8080
    initialDelaySeconds: 30
    periodSeconds: 10

    readinessProbe:
    httpGet:
    path: /health/readiness
    port: 8080
    initialDelaySeconds: 20
    periodSeconds: 5

    # 優雅關閉
    lifecycle:
    preStop:
    exec:
    command: [“curl”, “-XPOST”, “http://localhost:8080/shutdown”]


    values.yaml:環境變數化

    # values.yaml(預設值)
    replicas: 2

    image:
    repository: registry.example.com/oms/consumer-shopee
    version: latest
    pullPolicy: Always

    resources:
    requests:
    cpu: 100m
    memory: 256Mi
    limits:
    cpu: 1000m
    memory: 1Gi

    # values-prod.yaml(正式環境覆寫)
    replicas: 5

    image:
    version: v1.2.3
    pullPolicy: IfNotPresent

    resources:
    requests:
    cpu: 500m
    memory: 512Mi
    limits:
    cpu: 2000m
    memory: 2Gi

    部署指令

    # 開發環境
    helm upgrade –install consumer-shopee ./helm/oms-consumer-shopee \
    -f values-dev.yaml

    # 正式環境
    helm upgrade –install consumer-shopee ./helm/oms-consumer-shopee \
    -f values-prod.yaml \
    –set image.version=v1.2.3


    Secret 管理

    # 敏感資訊不放在 values.yaml
    env:
    – name: DB_USERNAME
    valueFrom:
    secretKeyRef:
    name: db-credentials
    key: username

    – name: DB_PASSWORD
    valueFrom:
    secretKeyRef:
    name: db-credentials
    key: password

    # 建立 Secret
    kubectl create secret generic db-credentials \
    –namespace oms \
    –from-literal=username=admin \
    –from-literal=password=secret123

    ConfigMap 管理

    # templates/configmap.yaml
    apiVersion: v1
    kind: ConfigMap
    metadata:
    name: {{ .Chart.Name }}-config
    data:
    {{ (.Files.Glob “config/*”).AsConfig | indent 2 }}
    效果:config/ 目錄下的檔案會自動打包成 ConfigMap

    健康檢查整合

    Probe 類型 端點 失敗行為
    liveness /health/liveness 重啟 Pod
    readiness /health/readiness 從 Service 移除
    # 分離 liveness 和 readiness
    livenessProbe:
    httpGet:
    path: /health/liveness
    port: 8080
    initialDelaySeconds: 30 # 啟動後等待時間
    periodSeconds: 10 # 檢查間隔
    timeoutSeconds: 5 # 超時時間
    failureThreshold: 3 # 失敗幾次後重啟

    readinessProbe:
    httpGet:
    path: /health/readiness
    port: 8080
    initialDelaySeconds: 20
    periodSeconds: 5
    failureThreshold: 3


    多通路部署策略

    17 個通路,每個都有獨立的 Helm Chart:

    helm/
    ├── oms-consumer-shopee/
    ├── oms-consumer-momo/
    ├── oms-consumer-yahoo/
    ├── oms-consumer-pchome/
    ├── oms-consumer-rakuten/
    ├── oms-consumer-shopify/
    ├── oms-consumer-shopline/
    └── … (共 17 個)

    批次部署腳本

    #!/bin/bash

    CHANNELS=(
    “shopee”
    “momo”
    “yahoo”
    “pchome”
    “rakuten”
    “shopify”
    “shopline”
    )

    VERSION=$1

    for channel in “${CHANNELS[@]}”; do
    echo “Deploying $channel…”

    helm upgrade –install “consumer-${channel}” \
    “./helm/oms-consumer-${channel}” \
    –set image.version=$VERSION \
    –namespace oms

    done

    echo “All channels deployed!”


    Service 與 Ingress

    # templates/service.yaml
    apiVersion: v1
    kind: Service
    metadata:
    name: {{ .Chart.Name }}
    namespace: oms
    spec:
    type: ClusterIP
    ports:
    – port: 80
    targetPort: 8080
    name: http
    selector:
    app: {{ .Chart.Name }}
    # Ingress 設定(API Gateway)
    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
    name: oms-api-gateway
    annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
    spec:
    rules:
    – host: api.example.com
    http:
    paths:
    – path: /orders
    pathType: Prefix
    backend:
    service:
    name: order-service
    port:
    number: 80

    CI/CD 整合

    # .gitlab-ci.yml
    stages:
    – build
    – deploy

    build:
    stage: build
    script:
    – docker build -t registry.example.com/oms/consumer-shopee:$CI_COMMIT_SHA .
    – docker push registry.example.com/oms/consumer-shopee:$CI_COMMIT_SHA

    deploy-dev:
    stage: deploy
    script:
    – helm upgrade –install consumer-shopee ./helm/oms-consumer-shopee
    –set image.version=$CI_COMMIT_SHA
    -f values-dev.yaml
    only:
    – develop

    deploy-prod:
    stage: deploy
    script:
    – helm upgrade –install consumer-shopee ./helm/oms-consumer-shopee
    –set image.version=$CI_COMMIT_TAG
    -f values-prod.yaml
    only:
    – tags
    when: manual


    實戰踩坑

    踩坑 1:ConfigMap 改了但 Pod 沒更新
    情境:改了 application.yml 但服務行為沒變
    原因:K8s 不會自動重啟 Pod,舊的 ConfigMap 還在記憶體中
    解法:在 Deployment 加 checksum annotation,ConfigMap 變化時觸發滾動更新
    踩坑 2:OOM Killed 但沒收到告警
    情境:服務突然重啟,查了半天才發現是記憶體不足
    原因:JVM heap 設定超過 container limits,被 K8s 強制 kill
    解法:JVM heap 設為 limits 的 70%,並設定 Prometheus 告警監控 OOM 事件
    踩坑 3:滾動更新時服務中斷
    情境:部署新版本時用戶收到 502 錯誤
    原因:新 Pod 還沒 ready 就把舊 Pod 砍掉,或 preStop 沒有 graceful shutdown
    解法:設好 readinessProbe + preStop hook,確保流量先切換再關閉

    總結

    設計 效果
    Helm Chart 標準化 每個服務結構一致,易於維護
    values 分環境 同一 Chart 部署不同環境
    ConfigMap 動態載入 配置與程式碼分離
    Secret 管理 敏感資訊安全儲存
    健康檢查整合 K8s 自動管理故障
    多通路獨立部署 故障隔離,獨立升級

    上一篇 系列目錄 完結
    OpenTracing分散式追蹤 系列導讀 本篇為系列最終篇

    這是「多通路電商 OMS 系統實戰」系列的最終篇。感謝閱讀,希望對你的系統設計有所幫助!

  • 分散式追蹤:OpenTracing 整合實戰

    商業價值:分散式追蹤讓「問題定位從小時變分鐘」,直接支援 導讀篇提到的「系統穩定性」——當訂單卡在某個環節時,能快速找到是哪個服務出問題。

    為什麼不用其他方案?

    方案 優點 缺點 適用場景
    B3 + Jaeger(本文) 業界標準、視覺化強 需要額外部署 微服務架構
    Log 關聯查詢 簡單 跨服務追蹤困難 單體應用
    Zipkin 輕量 功能較少 小型微服務
    商業 APM 功能完整 費用高 預算充足團隊

    前言:微服務的可觀測性挑戰

    在多通路系統中,一個訂單請求的流程可能是:

    使用者 → API Gateway → Order Service → Kafka → Consumer Job → 蝦皮 API

    資料庫更新

    回呼通知
    問題:當某個環節出問題時,如何追蹤請求在各服務之間的流向?

    這就是分散式追蹤(Distributed Tracing)要解決的問題。


    追蹤標準:B3 Propagation

    我們採用 B3 標準,定義一組 HTTP Header 來傳遞追蹤資訊:

    Header 說明 範例
    x-request-id 請求唯一識別碼 uuid-xxxx
    x-b3-traceid 追蹤 ID(整個請求鏈共用) abc123
    x-b3-spanid 當前操作 ID def456
    x-b3-parentspanid 父操作 ID abc123
    x-b3-sampled 是否取樣 1

    實作:傳遞追蹤 Header

    定義追蹤 Header 清單

    public class TracingHeaders {

    public static final List<String> HEADERS = List.of(
    “x-request-id”,
    “x-b3-traceid”,
    “x-b3-spanid”,
    “x-b3-parentspanid”,
    “x-b3-sampled”,
    “x-b3-flags”
    );
    }

    從 Request 提取追蹤 Header

    @Component
    public class TracingExtractor {

    /**
    * 從 HTTP Request 提取追蹤 Header
    */

    public Map<String, String> extract(HttpServletRequest request) {
    Map<String, String> tracingHeaders = new HashMap<>();

    for (String headerName : TracingHeaders.HEADERS) {
    String value = request.getHeader(headerName);
    if (value != null && !value.isEmpty()) {
    tracingHeaders.put(headerName, value);
    }
    }

    return tracingHeaders;
    }
    }


    使用情境

    情境 1:API 呼叫外部服務

    @RestController
    public class OrderController {

    @Autowired private TracingExtractor tracingExtractor;
    @Autowired private HttpClientService httpClient;

    @GetMapping(“/api/orders/{orderId}”)
    public Order getOrder(
    @PathVariable String orderId,
    HttpServletRequest request) {

    // 1. 提取追蹤 Header
    Map<String, String> tracingHeaders = tracingExtractor.extract(request);

    // 2. 呼叫外部 API,傳遞追蹤 Header
    HttpResult result = httpClient.get(
    “https://api.platform.com/orders/” + orderId,
    tracingHeaders
    );

    return parseOrder(result);
    }
    }

    情境 2:Kafka 訊息傳遞

    // Producer:將追蹤資訊放入訊息
    public String buildMessage(Object body, Map<String, String> tracingHeaders) {
    Map<String, Object> message = new HashMap<>();

    // Header 包含追蹤資訊
    Map<String, Object> header = new HashMap<>();
    header.put(“version”, 1);
    header.put(“tracing”, tracingHeaders);

    message.put(“header”, header);
    message.put(“body”, body);

    return JsonUtil.toJson(message);
    }

    // Consumer:從訊息取出追蹤資訊
    public void processMessage(String message) {
    Map<String, Object> data = JsonUtil.toMap(message);
    Map<String, Object> header = (Map) data.get(“header”);

    // 取出追蹤 Header
    Map<String, String> tracingHeaders = (Map) header.get(“tracing”);

    // 呼叫外部 API 時繼續傳遞
    HttpResult result = httpClient.get(platformApiUrl, tracingHeaders);
    }


    自動傳遞:Filter + ThreadLocal

    @Component
    public class TracingFilter implements Filter {

    @Autowired
    private TracingExtractor extractor;

    @Override
    public void doFilter(
    ServletRequest request,
    ServletResponse response,
    FilterChain chain) throws IOException, ServletException {

    HttpServletRequest httpRequest = (HttpServletRequest) request;

    // 提取追蹤 Header 放入 ThreadLocal
    Map<String, String> tracingHeaders = extractor.extract(httpRequest);
    TracingContext.set(tracingHeaders);

    try {
    chain.doFilter(request, response);
    } finally {
    TracingContext.clear();
    }
    }
    }

    // ThreadLocal 儲存
    public class TracingContext {

    private static final ThreadLocal<Map<String, String>> CONTEXT =
    new ThreadLocal<>();

    public static void set(Map<String, String> headers) {
    CONTEXT.set(headers);
    }

    public static Map<String, String> get() {
    Map<String, String> headers = CONTEXT.get();
    return headers != null ? headers : Collections.emptyMap();
    }

    public static void clear() {
    CONTEXT.remove();
    }
    }

    效果:HTTP 客戶端自動帶入追蹤 Header,開發者不用手動處理。

    整合 Jaeger

    Jaeger 是常用的分散式追蹤系統,可以視覺化追蹤鏈。

    Spring Boot 設定

    # application.yml
    opentracing:
    jaeger:
    enabled: true
    udp-sender:
    host: jaeger-agent
    port: 6831
    log-spans: true
    probabilistic-sampler:
    sampling-rate: 1.0 # 100% 取樣(生產環境調低)

    追蹤視覺化

    ┌─────────────────────────────────────────────────────────────────┐
    │ Trace: abc123 │
    ├─────────────────────────────────────────────────────────────────┤
    │ ▼ api-gateway [45ms] │
    │ ├─ order-service [30ms] │
    │ │ ├─ kafka-produce [5ms] │
    │ │ └─ db-query [10ms] │
    │ └─ consumer-job [25ms] │
    │ └─ platform-api [20ms] │
    └─────────────────────────────────────────────────────────────────┘

    錯誤追蹤

    @Aspect
    @Component
    public class TracingAspect {

    @Autowired
    private Tracer tracer;

    @Around(“execution(* com.example..*Service.*(..))”)
    public Object trace(ProceedingJoinPoint joinPoint) throws Throwable {
    Span span = tracer.buildSpan(joinPoint.getSignature().getName()).start();

    try {
    return joinPoint.proceed();

    } catch (Exception e) {
    // 記錄錯誤到追蹤系統
    span.setTag(“error”, true);
    span.log(Map.of(
    “event”, “error”,
    “error.message”, e.getMessage(),
    “error.class”, e.getClass().getName()
    ));
    throw e;

    } finally {
    span.finish();
    }
    }
    }


    實戰踩坑

    踩坑 1:ThreadLocal 洩漏到其他請求
    情境:偶發看到 traceId 對不上,追蹤鏈混亂
    原因:Filter 的 finally 沒有正確清理 ThreadLocal,執行緒池重用導致污染
    解法:確保 TracingContext.clear() 一定會執行,可用 try-finally 或 Spring 的 RequestContextHolder
    踩坑 2:Kafka Consumer 追蹤斷裂
    情境:Producer 到 Consumer 的追蹤連不起來
    原因:只在 HTTP 層傳遞 Header,忘了在 Kafka 訊息中嵌入追蹤資訊
    解法:Producer 把 tracingHeaders 放進訊息 header,Consumer 取出後設回 ThreadLocal
    踩坑 3:取樣率設 100% 撐爆 Jaeger
    情境:Jaeger 儲存空間暴增,查詢變慢
    原因:生產環境忘了調低 sampling-rate,每個請求都記錄
    解法:生產環境設 0.1(10%)或更低,搭配動態取樣根據錯誤率調整

    總結

    設計 效果
    B3 標準 Header 相容主流追蹤系統
    Filter 自動提取 開發者不需手動處理
    ThreadLocal 儲存 任何地方都能取得追蹤資訊
    Kafka 嵌入 異步訊息也能追蹤
    Jaeger 整合 視覺化追蹤鏈

    上一篇 系列目錄 下一篇
    JSON處理與時區管理 系列導讀 Kubernetes Helm Charts 部署

    這是「多通路電商 OMS 系統實戰」系列的第九篇。下一篇會介紹 Kubernetes 部署。

  • Jackson JSON 序列化:時區與日期處理

    商業價值:正確的時區處理讓「跨平台訂單時間一致」,避免 導讀篇提到的「超賣損失」——如果訂單時間錯誤,先後順序就會亂,庫存扣減就會出問題。

    為什麼不用其他方案?

    方案 優點 缺點 適用場景
    統一 ObjectMapper(本文) 全系統一致、自動時區轉換 需要初期設定 多平台整合系統
    各處自行處理 彈性高 格式不一致、時區 bug 頻發 單一平台小專案
    字串直接儲存 簡單 無法比較、排序困難 純展示用途
    Gson 輕量 時區支援較弱、擴展性差 簡單 JSON 處理

    前言:跨時區系統的日期處理

    多通路系統需要處理各種日期格式:

    平台 日期格式 範例
    蝦皮 Unix timestamp 1710748800
    Yahoo ISO 8601 2024-03-18T15:30:00+08:00
    Momo 台灣時間字串 2024/03/18 15:30:00
    資料庫 UTC 2024-03-18T07:30:00Z
    問題:如果沒有統一處理,時區 bug 會在各種轉換中出現

    解決方案:統一 JSON 處理

    ObjectMapper 設定

    @Configuration
    public class JsonConfig {

    @Bean
    public ObjectMapper objectMapper() {
    ObjectMapper mapper = new ObjectMapper();

    // 1. 允許序列化私有欄位
    mapper.setVisibility(PropertyAccessor.FIELD, Visibility.ANY);

    // 2. 日期格式設定
    mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    mapper.setDateFormat(new SimpleDateFormat(“yyyy-MM-dd’T’HH:mm:ss.SSS’Z’”));

    // 3. 設定 UTC 時區
    mapper.setTimeZone(TimeZone.getTimeZone(“UTC”));

    // 4. 支援 Java 8 時間 API
    mapper.registerModule(new JavaTimeModule());

    // 5. 自定義序列化器
    SimpleModule module = new SimpleModule();
    module.addSerializer(ZonedDateTime.class, new ZonedDateTimeSerializer());
    mapper.registerModule(module);

    // 6. 忽略未知欄位(平台加新欄位不會報錯)
    mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

    return mapper;
    }
    }

    設定 效果
    FIELD visibility 不需要 getter 也能序列化
    UTC 時區 系統內部統一用 UTC
    JavaTimeModule 支援 LocalDate、ZonedDateTime 等
    忽略未知欄位 API 加新欄位不會報錯

    自定義日期序列化器

    public class ZonedDateTimeSerializer extends JsonSerializer<ZonedDateTime> {

    private static final DateTimeFormatter FORMATTER =
    DateTimeFormatter.ofPattern(“yyyy-MM-dd’T’HH:mm:ss.SSS’Z’”);

    @Override
    public void serialize(
    ZonedDateTime value,
    JsonGenerator gen,
    SerializerProvider provider) throws IOException {

    // 無論輸入什麼時區,都轉成 UTC 輸出
    ZonedDateTime utc = value.withZoneSameInstant(ZoneId.of(“UTC”));
    gen.writeString(FORMATTER.format(utc));
    }
    }

    效果展示

    // 輸入:台灣時間
    ZonedDateTime taiwanTime = ZonedDateTime.now(ZoneId.of(“Asia/Taipei”));
    // 2024-03-18T15:30:00+08:00[Asia/Taipei]

    // 輸出:自動轉成 UTC
    String json = objectMapper.writeValueAsString(Map.of(“time”, taiwanTime));
    // {“time”:”2024-03-18T07:30:00.000Z”}


    JSON 工具類

    @Component
    public class JsonUtil {

    private static ObjectMapper mapper;

    @Autowired
    public void setMapper(ObjectMapper mapper) {
    JsonUtil.mapper = mapper;
    }

    /**
    * 物件轉 JSON
    */

    public static String toJson(Object obj) {
    try {
    return mapper.writeValueAsString(obj);
    } catch (JsonProcessingException e) {
    throw new JsonException(“序列化失敗”, e);
    }
    }

    /**
    * JSON 轉物件
    */

    public static <T> T fromJson(String json, Class<T> clazz) {
    try {
    return mapper.readValue(json, clazz);
    } catch (JsonProcessingException e) {
    throw new JsonException(“反序列化失敗”, e);
    }
    }

    /**
    * JSON 轉泛型物件
    */

    public static <T> T fromJson(String json, TypeReference<T> type) {
    try {
    return mapper.readValue(json, type);
    } catch (JsonProcessingException e) {
    throw new JsonException(“反序列化失敗”, e);
    }
    }

    /**
    * JSON 轉 List
    */

    public static <T> List<T> fromJsonList(String json, Class<T> clazz) {
    try {
    JavaType type = mapper.getTypeFactory()
    .constructCollectionType(List.class, clazz);
    return mapper.readValue(json, type);
    } catch (JsonProcessingException e) {
    throw new JsonException(“反序列化失敗”, e);
    }
    }

    /**
    * JSON 轉 Map
    */

    public static Map<String, Object> toMap(String json) {
    return fromJson(json, new TypeReference<>() {});
    }
    }


    平台日期轉換

    public class DateConverter {

    /**
    * Unix timestamp → ZonedDateTime(蝦皮)
    */

    public static ZonedDateTime fromUnixTimestamp(long timestamp) {
    return Instant.ofEpochSecond(timestamp)
    .atZone(ZoneId.of(“UTC”));
    }

    /**
    * ISO 8601 → ZonedDateTime(Yahoo)
    */

    public static ZonedDateTime fromISO(String isoString) {
    return ZonedDateTime.parse(isoString);
    }

    /**
    * 台灣時間字串 → ZonedDateTime(Momo)
    */

    public static ZonedDateTime fromTaiwanTime(String timeStr) {
    DateTimeFormatter formatter =
    DateTimeFormatter.ofPattern(“yyyy/MM/dd HH:mm:ss”);

    LocalDateTime ldt = LocalDateTime.parse(timeStr, formatter);
    return ldt.atZone(ZoneId.of(“Asia/Taipei”));
    }

    /**
    * 轉成台灣時間顯示
    */

    public static String toTaiwanDisplay(ZonedDateTime time) {
    ZonedDateTime taiwanTime = time.withZoneSameInstant(
    ZoneId.of(“Asia/Taipei”)
    );
    return taiwanTime.format(
    DateTimeFormatter.ofPattern(“yyyy/MM/dd HH:mm:ss”)
    );
    }
    }


    使用範例

    // 從蝦皮 API 取得資料
    long shopeeTimestamp = 1710748800;
    ZonedDateTime orderTime = DateConverter.fromUnixTimestamp(shopeeTimestamp);

    // 存入資料庫(UTC)
    order.setCreatedAt(orderTime);
    orderRepository.save(order);

    // 回傳 API(自動轉成 UTC JSON)
    return JsonUtil.toJson(order);
    // {“createdAt”:”2024-03-18T08:00:00.000Z”}

    // 前端顯示(轉成台灣時間)
    String display = DateConverter.toTaiwanDisplay(order.getCreatedAt());
    // 2024/03/18 16:00:00


    實戰踩坑

    踩坑 1:時區雙重轉換
    情境:前端顯示時間比實際晚 8 小時
    原因:資料庫已經是 UTC,但讀取時又被當成本地時間再轉一次 UTC
    解法:確保 JDBC 連線設定 serverTimezone=UTC,並且 Entity 用 ZonedDateTime 而非 Date
    踩坑 2:蝦皮 timestamp 單位搞錯
    情境:訂單時間顯示成 1970 年
    原因:蝦皮回傳秒級 timestamp,但程式用 Instant.ofEpochMilli() 處理
    解法:確認平台 API 文件的時間單位,秒用 ofEpochSecond,毫秒用 ofEpochMilli
    踩坑 3:SimpleDateFormat 執行緒不安全
    情境:高併發時偶發日期解析錯誤或 NumberFormatException
    原因:把 SimpleDateFormat 設成 static 共用
    解法:改用 DateTimeFormatter(執行緒安全),或每次 new 新的 SimpleDateFormat

    總結

    設計 效果
    統一 ObjectMapper 全系統一致的 JSON 處理
    UTC 儲存 避免時區混亂
    自定義序列化 控制輸出格式
    平台轉換器 各平台格式統一處理

    上一篇 系列目錄 下一篇
    PDF生成與Builder Pattern 系列導讀 OpenTracing分散式追蹤

    這是「多通路電商 OMS 系統實戰」系列的第八篇。下一篇會介紹分散式追蹤。

  • PDF 生成最佳實踐:Builder 模式的優雅 API

    商業價值:統一的 PDF 生成讓「出貨單、撿貨單一鍵產生」,直接支撐 導讀篇提到「處理速度提升 10 倍」——從每單 3 分鐘縮短到批次 1 秒。

    前言:電商系統的 PDF 需求

    OMS 系統需要產生各種 PDF 文件:

    文件類型 用途 特殊需求
    出貨標籤 貼在包裹上 條碼、收件人資訊
    訂單明細 放在包裹內 商品清單、金額
    撿貨單 倉庫作業 商品位置、數量
    對帳單 商戶結算 表格、統計
    挑戰:傳統 PDF 產生程式碼冗長、難以維護

    解決方案:Builder 模式

    使用範例(先看效果)

    // 產生出貨單 PDF
    byte[] pdf = PdfBuilder.create()
    .title(“出貨單”)
    .separator()
    .text(“訂單編號:ORD-2024-001234”)
    .text(“出貨日期:2024-03-18”)
    .newLine()
    .subtitle(“商品明細”)
    .table(orderItems)
    .newLine()
    .subtitle(“收件資訊”)
    .text(“收件人:王小明”)
    .text(“電話:0912-345-678”)
    .text(“地址:台北市信義區信義路五段7號”)
    .newPage()
    .image(barcodeBytes)
    .build();
    效果:程式碼像文件結構一樣直覺,易讀易維護

    PdfBuilder 實作

    核心結構

    public class PdfBuilder {

    private List<Element> elements = new ArrayList<>();
    private FontConfig fontConfig;

    // 工廠方法
    public static PdfBuilder create() {
    return new PdfBuilder(FontConfig.defaultConfig());
    }

    public static PdfBuilder create(FontConfig config) {
    return new PdfBuilder(config);
    }

    private PdfBuilder(FontConfig config) {
    this.fontConfig = config;
    }
    }

    文字方法

    /**
    * 標題(最大字體)
    */

    public PdfBuilder title(String text) {
    elements.add(new TextElement(text, fontConfig.getTitleFont()));
    return this; // 回傳 this 支援鏈式呼叫
    }

    /**
    * 副標題
    */

    public PdfBuilder subtitle(String text) {
    elements.add(new TextElement(text, fontConfig.getSubtitleFont()));
    return this;
    }

    /**
    * 一般文字
    */

    public PdfBuilder text(String text) {
    elements.add(new TextElement(text, fontConfig.getBodyFont()));
    return this;
    }

    /**
    * 粗體文字
    */

    public PdfBuilder boldText(String text) {
    elements.add(new TextElement(text, fontConfig.getBoldFont()));
    return this;
    }

    版面控制

    /**
    * 換行
    */

    public PdfBuilder newLine() {
    elements.add(new NewLineElement());
    return this;
    }

    /**
    * 分隔線
    */

    public PdfBuilder separator() {
    elements.add(new SeparatorElement());
    return this;
    }

    /**
    * 換頁
    */

    public PdfBuilder newPage() {
    elements.add(new NewPageElement());
    return this;
    }

    表格支援

    /**
    * 新增表格
    * @param data 二維資料,第一列為標題
    */

    public PdfBuilder table(List<List<String>> data) {
    elements.add(new TableElement(data, TableStyle.BORDERED));
    return this;
    }

    /**
    * 新增表格(指定樣式)
    */

    public PdfBuilder table(List<List<String>> data, TableStyle style) {
    elements.add(new TableElement(data, style));
    return this;
    }

    圖片與條碼

    /**
    * 新增圖片
    */

    public PdfBuilder image(byte[] imageBytes) {
    elements.add(new ImageElement(imageBytes));
    return this;
    }

    /**
    * 新增條碼(自動產生)
    */

    public PdfBuilder barcode(String content) {
    byte[] barcodeImage = BarcodeGenerator.generate(content);
    elements.add(new ImageElement(barcodeImage));
    return this;
    }


    輸出 PDF

    /**
    * 產生 PDF byte 陣列
    */

    public byte[] build() {
    ByteArrayOutputStream output = new ByteArrayOutputStream();
    Document document = new Document(PageSize.A4);

    try {
    PdfWriter.getInstance(document, output);
    document.open();

    for (Element element : elements) {
    if (element instanceof NewPageElement) {
    document.newPage();
    } else {
    document.add(element.render());
    }
    }

    document.close();

    } catch (DocumentException e) {
    throw new PdfGenerationException(“PDF 產生失敗”, e);
    }

    return output.toByteArray();
    }

    /**
    * 直接寫入檔案
    */

    public void toFile(String filename) {
    byte[] pdf = build();
    try (FileOutputStream fos = new FileOutputStream(filename)) {
    fos.write(pdf);
    } catch (IOException e) {
    throw new PdfGenerationException(“檔案寫入失敗”, e);
    }
    }


    中文字型支援

    public class FontConfig {

    private static final String FONT_PATH = “/fonts/NotoSansTC-Regular.ttf”;
    private BaseFont baseFont;

    public static FontConfig defaultConfig() {
    return new FontConfig();
    }

    private FontConfig() {
    try {
    // 載入支援中文的字型
    baseFont = BaseFont.createFont(
    FONT_PATH,
    BaseFont.IDENTITY_H, // Unicode 支援
    BaseFont.EMBEDDED // 嵌入字型
    );
    } catch (Exception e) {
    throw new RuntimeException(“字型載入失敗”, e);
    }
    }

    public Font getTitleFont() {
    return new Font(baseFont, 24, Font.BOLD);
    }

    public Font getSubtitleFont() {
    return new Font(baseFont, 18, Font.BOLD);
    }

    public Font getBodyFont() {
    return new Font(baseFont, 12, Font.NORMAL);
    }

    public Font getBoldFont() {
    return new Font(baseFont, 12, Font.BOLD);
    }
    }


    完整範例:出貨單

    public byte[] generateShippingLabel(Order order) {
    // 準備商品明細表格
    List<List<String>> items = new ArrayList<>();
    items.add(List.of(“商品”, “數量”, “單價”, “小計”));

    for (OrderItem item : order.getItems()) {
    items.add(List.of(
    item.getName(),
    String.valueOf(item.getQuantity()),
    formatPrice(item.getPrice()),
    formatPrice(item.getSubtotal())
    ));
    }

    items.add(List.of(“合計”, “”, “”, formatPrice(order.getTotal())));

    // 產生 PDF
    return PdfBuilder.create()
    // 標題區
    .title(“出貨單”)
    .separator()
    .text(“訂單編號:” + order.getOrderId())
    .text(“出貨日期:” + LocalDate.now())
    .newLine()

    // 條碼
    .barcode(order.getOrderId())
    .newLine()

    // 商品明細
    .subtitle(“商品明細”)
    .table(items)
    .newLine()

    // 收件資訊
    .subtitle(“收件資訊”)
    .text(“收件人:” + order.getReceiverName())
    .text(“電話:” + order.getReceiverPhone())
    .text(“地址:” + order.getReceiverAddress())

    .build();
    }


    總結

    設計 效果
    Builder 模式 鏈式呼叫,程式碼簡潔
    流式 API 像寫 HTML 一樣直覺
    字型抽象 換字型只改一處
    元件化 表格、圖片、條碼都是元件

    為什麼不用其他方案?

    方案 優點 缺點 結論
    HTML 轉 PDF 會 HTML 就會用 排版難控制、分頁問題 簡單報表可用
    Word 範本 業務人員能改 需要額外軟體、格式問題 不推薦
    JasperReports 功能強大 學習曲線陡、設計器難用 複雜報表可考慮
    iText + Builder 程式碼控制、可測試 要寫程式碼 工程師友好

    實戰踩坑

    坑 1:中文字型問題

    預設字型不支援中文,產出的 PDF 全是方框。要嵌入支援中文的字型(如 Noto Sans TC)。第一次載入字型要 2-3 秒,後來改成應用程式啟動時預載。

    坑 2:出貨單格式每平台不同

    蝦皮、Momo、Yahoo 的出貨單長得不一樣。最初想做成一模一樣,後來發現不可能(平台會檢查格式)。解法:只統一「我們自己的出貨單」,平台的標籤用平台 API 下載。

    坑 3:大量 PDF 記憶體爆炸

    一次產生 500 張出貨單,記憶體直接爆。解法:改成串流處理,產一張輸出一張,不要全部放在記憶體。


    系列導航

    ◀ 上一篇
    HTTP 客戶端
    📚 返回目錄 下一篇 ▶
    JSON 序列化
  • HTTP 客戶端設計:OkHttp 連接池與多場景應用

    商業價值:穩定的 HTTP 客戶端讓系統「能可靠地跟 17 個平台通訊」,這是 導讀篇提到「99% 庫存準確率」的基礎——API 不穩定,庫存同步就會失敗。

    前言:呼叫外部 API 的挑戰

    多通路系統需要呼叫大量外部 API:

    API 類型 特性 挑戰
    蝦皮 API 流量限制嚴格 需要控制請求頻率
    物流 API 回應慢 需要較長逾時
    支付 API 高可靠性要求 需要重試機制
    問題:每次都建立新連線 → 效能差、資源浪費

    解決方案:OkHttp 連線池

    連線池設定

    @Configuration
    public class HttpClientConfig {

    @Bean
    public OkHttpClient okHttpClient() {
    return new OkHttpClient.Builder()
    // 連線池設定
    .connectionPool(new ConnectionPool(
    100, // 最大閒置連線數
    5, TimeUnit.MINUTES // 閒置時間
    ))

    // 逾時設定
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .writeTimeout(30, TimeUnit.SECONDS)

    // 重試
    .retryOnConnectionFailure(true)

    .build();
    }
    }

    設定 說明
    maxIdleConnections 100 最多保持 100 條閒置連線
    keepAliveDuration 5 分鐘 閒置連線保持時間
    connectTimeout 10 秒 建立連線逾時
    readTimeout 30 秒 讀取回應逾時

    HTTP 客戶端封裝

    @Component
    public class HttpClientService {

    @Autowired
    private OkHttpClient okHttpClient;

    /**
    * GET 請求
    */

    public HttpResult get(String url, Map<String, String> headers) {
    Request request = new Request.Builder()
    .url(url)
    .headers(Headers.of(headers))
    .get()
    .build();

    return execute(request);
    }

    /**
    * POST 請求(JSON)
    */

    public HttpResult postJson(String url, Object body, Map<String, String> headers) {
    String json = JsonUtil.toJson(body);

    Request request = new Request.Builder()
    .url(url)
    .headers(Headers.of(headers))
    .post(RequestBody.create(json, MediaType.parse(“application/json”)))
    .build();

    return execute(request);
    }

    /**
    * POST 請求(Form)
    */

    public HttpResult postForm(String url, Map<String, String> params, Map<String, String> headers) {
    FormBody.Builder formBuilder = new FormBody.Builder();
    params.forEach(formBuilder::add);

    Request request = new Request.Builder()
    .url(url)
    .headers(Headers.of(headers))
    .post(formBuilder.build())
    .build();

    return execute(request);
    }

    private HttpResult execute(Request request) {
    try (Response response = okHttpClient.newCall(request).execute()) {
    return HttpResult.builder()
    .statusCode(response.code())
    .body(response.body() != null ? response.body().string() : null)
    .headers(response.headers().toMultimap())
    .success(response.isSuccessful())
    .build();

    } catch (IOException e) {
    return HttpResult.builder()
    .success(false)
    .errorMessage(e.getMessage())
    .build();
    }
    }
    }


    回應結果封裝

    @Data
    @Builder
    public class HttpResult {
    private boolean success;
    private int statusCode;
    private String body;
    private Map<String, List<String>> headers;
    private String errorMessage;

    /**
    * 解析 JSON 回應
    */

    public <T> T parseJson(Class<T> clazz) {
    if (!success || body == null) {
    return null;
    }
    return JsonUtil.fromJson(body, clazz);
    }

    /**
    * 解析 JSON 陣列回應
    */

    public <T> List<T> parseJsonList(Class<T> clazz) {
    if (!success || body == null) {
    return Collections.emptyList();
    }
    return JsonUtil.fromJsonList(body, clazz);
    }
    }


    追蹤 Header 傳遞

    支援分散式追蹤,自動傳遞追蹤 Header:

    @Component
    public class TracingHttpClient {

    private static final List<String> TRACING_HEADERS = List.of(
    “x-request-id”,
    “x-b3-traceid”,
    “x-b3-spanid”,
    “x-b3-parentspanid”,
    “x-b3-sampled”
    );

    @Autowired
    private HttpClientService httpClient;

    /**
    * 從當前請求提取追蹤 Header
    */

    public Map<String, String> extractTracingHeaders(HttpServletRequest request) {
    Map<String, String> headers = new HashMap<>();

    for (String name : TRACING_HEADERS) {
    String value = request.getHeader(name);
    if (value != null) {
    headers.put(name, value);
    }
    }

    return headers;
    }

    /**
    * 發送請求,自動帶入追蹤 Header
    */

    public HttpResult getWithTracing(String url, HttpServletRequest currentRequest) {
    Map<String, String> headers = extractTracingHeaders(currentRequest);
    return httpClient.get(url, headers);
    }
    }


    重試機制

    @Component
    public class RetryableHttpClient {

    @Autowired
    private HttpClientService httpClient;

    /**
    * 帶重試的請求
    */

    public HttpResult getWithRetry(String url, Map<String, String> headers, int maxRetries) {
    int attempt = 0;
    HttpResult result = null;

    while (attempt < maxRetries) {
    result = httpClient.get(url, headers);

    if (result.isSuccess()) {
    return result;
    }

    // 只對可重試的錯誤重試
    if (!isRetryable(result.getStatusCode())) {
    break;
    }

    attempt++;
    sleep(calculateBackoff(attempt));
    }

    return result;
    }

    private boolean isRetryable(int statusCode) {
    // 5xx 錯誤和 429 (Too Many Requests) 可重試
    return statusCode >= 500 || statusCode == 429;
    }

    private long calculateBackoff(int attempt) {
    // 指數退避:1秒, 2秒, 4秒…
    return (long) Math.pow(2, attempt – 1) * 1000;
    }
    }

    HTTP 狀態碼 是否重試 原因
    2xx 不需要 成功
    4xx (非 429) 不重試 客戶端錯誤,重試也沒用
    429 重試 流量限制,稍後重試
    5xx 重試 伺服器暫時錯誤

    使用範例

    @Service
    public class ShopeeApiClient {

    @Autowired
    private RetryableHttpClient httpClient;

    public List<ShopeeOrder> getOrders(String accessToken) {
    String url = “https://partner.shopeemobile.com/api/v2/order/get_order_list”;

    Map<String, String> headers = Map.of(
    “Authorization”, “Bearer “ + accessToken,
    “Content-Type”, “application/json”
    );

    HttpResult result = httpClient.getWithRetry(url, headers, 3);

    if (!result.isSuccess()) {
    throw new ApiException(“Shopee API 錯誤: “ + result.getErrorMessage());
    }

    return result.parseJsonList(ShopeeOrder.class);
    }
    }


    總結

    設計 效果
    連線池 復用連線,減少建立成本
    逾時設定 避免請求無限等待
    結果封裝 統一處理成功/失敗
    追蹤 Header 支援分散式追蹤
    重試機制 自動處理暫時性錯誤

    為什麼不用其他方案?

    方案 優點 缺點 結論
    HttpURLConnection JDK 內建 API 難用、功能少 不推薦
    Apache HttpClient 功能完整 API 複雜、依賴多 可用但重
    Spring RestTemplate Spring 整合好 已被標記為 maintenance 舊專案可用
    Spring WebClient 非同步、Reactive 學習曲線、除錯困難 Reactive 專案用
    OkHttp 輕量、效能好、API 簡潔 非 Spring 原生 同步 HTTP 首選

    實戰踩坑

    坑 1:連線池用完了

    早期沒設連線池,每次請求都建新連線。流量一大,系統噴 Connection refused。加上連線池後,效能提升 10 倍,問題消失。

    坑 2:逾時設太長

    最初 readTimeout 設 60 秒。某平台 API 掛了,執行緒都在等待,整個服務卡死。改成 30 秒 + 重試機制後,就算 API 慢也能處理。

    坑 3:沒處理 429 Too Many Requests

    蝦皮有流量限制,瘋狂打 API 會收到 429。最初沒處理,一直重試反而更慢。加上指數退避(1秒、2秒、4秒…)後,流量限制問題大幅改善。


    系列導航

    ◀ 上一篇
    DTO 設計
    📚 返回目錄 下一篇 ▶
    PDF 生成
  • DTO 地獄求生指南:管理數百個資料傳輸物件

    商業價值:良好的 DTO 設計讓「平台 API 變更只影響一個類別」,這是 導讀篇提到「新增平台 2-3 週上線」的技術基礎。

    前言:DTO 地獄

    在多通路系統中,每個平台的資料格式都不同:

    平台 訂單編號欄位 金額欄位 時間格式
    蝦皮 order_sn total_amount Unix timestamp
    Momo orderNo orderAmount yyyy/MM/dd HH:mm
    Yahoo OrderId TotalPrice ISO 8601
    PChome order_id amount yyyy-MM-dd
    問題:如果每個平台都用不同的 DTO,會有數百個類別,維護困難。

    解決方案:三層 DTO 架構

    設計原則:

    1. 外部 DTO:對應平台 API 的原始格式
    2. 內部 DTO:系統統一的資料格式
    3. 轉換器:負責格式轉換

    層次說明

    層次 命名規則 範例 用途
    外部 DTO {Platform}{Action}Request/Response ShopeeGetOrderResponse 對應平台 API
    內部 DTO {Entity}DTO OrderDTO 系統內部傳輸
    轉換器 {Platform}Converter ShopeeConverter 格式轉換

    外部 DTO:對應平台 API

    /**
    * 蝦皮訂單 API 回應(對應蝦皮 API 文件)
    */

    @Data
    public class ShopeeGetOrderResponse {

    // 蝦皮欄位名稱(底線命名)
    @JsonProperty(“order_sn”)
    private String orderSn;

    @JsonProperty(“order_status”)
    private String orderStatus;

    @JsonProperty(“total_amount”)
    private BigDecimal totalAmount;

    @JsonProperty(“create_time”)
    private Long createTime; // Unix timestamp

    @JsonProperty(“buyer_username”)
    private String buyerUsername;

    @JsonProperty(“item_list”)
    private List<ShopeeOrderItem> itemList;
    }

    /**
    * Momo 訂單 API 回應(對應 Momo API 文件)
    */

    @Data
    public class MomoGetOrderResponse {

    // Momo 欄位名稱(駝峰命名)
    private String orderNo;
    private String orderStatus;
    private BigDecimal orderAmount;
    private String orderDate; // yyyy/MM/dd HH:mm
    private String customerName;
    private List<MomoOrderItem> products;
    }


    內部 DTO:統一格式

    /**
    * 系統內部訂單 DTO(統一格式)
    */

    @Data
    @Builder
    public class OrderDTO {

    // 統一的欄位命名
    private String orderId;
    private String platformOrderId;
    private ChannelType channel;
    private OrderStatus status;
    private BigDecimal totalAmount;
    private ZonedDateTime createdAt; // 統一用 ZonedDateTime
    private String buyerName;
    private List<OrderItemDTO> items;
    }


    轉換器:格式轉換

    @Component
    public class ShopeeConverter {

    /**
    * 蝦皮格式 → 內部格式
    */

    public OrderDTO toOrderDTO(ShopeeGetOrderResponse response) {
    return OrderDTO.builder()
    .platformOrderId(response.getOrderSn())
    .channel(ChannelType.SHOPEE)
    .status(mapStatus(response.getOrderStatus()))
    .totalAmount(response.getTotalAmount())
    .createdAt(convertTimestamp(response.getCreateTime()))
    .buyerName(response.getBuyerUsername())
    .items(convertItems(response.getItemList()))
    .build();
    }

    // Unix timestamp → ZonedDateTime
    private ZonedDateTime convertTimestamp(Long timestamp) {
    return Instant.ofEpochSecond(timestamp)
    .atZone(ZoneId.of(“Asia/Taipei”));
    }

    // 蝦皮狀態 → 系統狀態
    private OrderStatus mapStatus(String shopeeStatus) {
    return switch (shopeeStatus) {
    case “UNPAID” -> OrderStatus.PENDING_PAYMENT;
    case “READY_TO_SHIP” -> OrderStatus.PENDING_SHIPMENT;
    case “SHIPPED” -> OrderStatus.SHIPPED;
    case “COMPLETED” -> OrderStatus.COMPLETED;
    case “CANCELLED” -> OrderStatus.CANCELLED;
    default -> OrderStatus.UNKNOWN;
    };
    }
    }

    @Component
    public class MomoConverter {

    private static final DateTimeFormatter MOMO_DATE_FORMAT =
    DateTimeFormatter.ofPattern(“yyyy/MM/dd HH:mm”);

    /**
    * Momo 格式 → 內部格式
    */

    public OrderDTO toOrderDTO(MomoGetOrderResponse response) {
    return OrderDTO.builder()
    .platformOrderId(response.getOrderNo())
    .channel(ChannelType.MOMO)
    .status(mapStatus(response.getOrderStatus()))
    .totalAmount(response.getOrderAmount())
    .createdAt(convertDate(response.getOrderDate()))
    .buyerName(response.getCustomerName())
    .items(convertItems(response.getProducts()))
    .build();
    }

    // Momo 日期格式 → ZonedDateTime
    private ZonedDateTime convertDate(String dateStr) {
    LocalDateTime ldt = LocalDateTime.parse(dateStr, MOMO_DATE_FORMAT);
    return ldt.atZone(ZoneId.of(“Asia/Taipei”));
    }
    }


    狀態對照表

    不同平台的訂單狀態對照:

    系統狀態 蝦皮 Momo Yahoo
    PENDING_PAYMENT UNPAID 01 Unpaid
    PENDING_SHIPMENT READY_TO_SHIP 02 Processing
    SHIPPED SHIPPED 03 Shipped
    COMPLETED COMPLETED 04 Completed
    CANCELLED CANCELLED 99 Cancelled

    使用範例

    @Service
    public class OrderSyncService {

    @Autowired private ShopeeConverter shopeeConverter;
    @Autowired private MomoConverter momoConverter;

    public List<OrderDTO> syncOrders(ChannelType channel, Merchant merchant) {

    if (channel == ChannelType.SHOPEE) {
    // 呼叫蝦皮 API,取得蝦皮格式
    List<ShopeeGetOrderResponse> shopeeOrders = shopeeApi.getOrders();

    // 轉換成內部格式
    return shopeeOrders.stream()
    .map(shopeeConverter::toOrderDTO)
    .toList();
    }

    if (channel == ChannelType.MOMO) {
    // 呼叫 Momo API,取得 Momo 格式
    List<MomoGetOrderResponse> momoOrders = momoApi.getOrders();

    // 轉換成內部格式
    return momoOrders.stream()
    .map(momoConverter::toOrderDTO)
    .toList();
    }

    // … 其他平台
    }
    }

    效果:業務邏輯層只處理統一的 OrderDTO,不用關心各平台的差異。

    總結

    設計 效果
    外部 DTO 對應 API API 變更只影響一個類別
    內部 DTO 統一格式 業務邏輯不受平台影響
    獨立轉換器 轉換邏輯集中管理
    狀態對照表 統一的訂單狀態

    為什麼不用其他方案?

    方案 優點 缺點 結論
    直接用 Map 不用定義類別 無型別安全、IDE 無法幫忙 除錯困難
    一個 DTO 打天下 類別數量少 欄位爆炸、不知道哪些是哪個平台 維護噩夢
    MapStruct 自動轉換 減少手寫程式碼 複雜轉換還是要手寫 可搭配使用
    三層 DTO + 轉換器 清晰、可測試 類別數量多 大型系統首選

    實戰踩坑

    坑 1:平台欄位名稱一直變

    蝦皮某次升級把 item_list 改成 items,只有外部 DTO 需要改,業務邏輯完全不受影響。如果沒有分層,全系統都要搜尋取代。

    坑 2:狀態對照表不完整

    PChome 新增了一個「部分出貨」狀態,我們的對照表沒有,結果 mapping 成 UNKNOWN,訂單卡住不處理。教訓:每個平台的狀態值要定期 review

    坑 3:Converter 邏輯越來越肥

    最初 Converter 只做欄位 mapping,後來塞進去驗證、預設值、業務邏輯…變成 God Class。後來拆成 Converter(純 mapping)+ Validator + Enricher,各司其職。


    系列導航

    ◀ 上一篇
    健康檢查
    📚 返回目錄 下一篇 ▶
    HTTP 客戶端
  • 分佈式健康檢查:自定義 Spring Boot Actuator

    商業價值:健康檢查讓系統「自動發現問題、自動恢復」,直接支撐 導讀篇提到的 99% 庫存準確率——系統不穩定就不可能有準確的庫存。

    前言:為什麼需要健康檢查?

    在微服務架構中,一個服務可能依賴多個外部元件:

    元件 用途 掛掉的影響
    PostgreSQL 主資料庫 無法讀寫訂單
    Redis 快取 效能下降
    Kafka 訊息佇列 無法非同步處理
    Solr 搜尋引擎 無法搜尋訂單
    問題:Kubernetes 預設只檢查 HTTP 回應,無法知道資料庫是否正常。

    Spring Boot Actuator 健康檢查

    基本設定

    # application.yml
    management:
    endpoints:
    web:
    base-path: /
    exposure:
    include: health, info, metrics

    endpoint:
    health:
    show-details: always
    show-components: always

    health:
    # 啟用各元件的健康檢查
    db:
    enabled: true
    redis:
    enabled: true

    健康檢查端點

    端點 用途 使用場景
    /health 完整健康狀態 監控系統
    /health/liveness 存活檢查 K8s liveness probe
    /health/readiness 就緒檢查 K8s readiness probe

    自定義健康檢查指標

    Kafka 健康檢查

    @Component
    public class KafkaHealthIndicator implements HealthIndicator {

    @Value(“${kafka.bootstrap-servers}”)
    private String bootstrapServers;

    private AtomicReference<Health> cachedHealth =
    new AtomicReference<>(Health.unknown().build());

    @Override
    public Health health() {
    return cachedHealth.get();
    }

    /**
    * 背景執行緒定期檢查,避免阻塞健康檢查端點
    */

    @Scheduled(fixedRate = 30000) // 每 30 秒檢查一次
    public void checkHealth() {
    try {
    Properties props = new Properties();
    props.put(“bootstrap.servers”, bootstrapServers);
    props.put(“request.timeout.ms”, “5000”);

    try (AdminClient admin = AdminClient.create(props)) {
    admin.listTopics().names().get(5, TimeUnit.SECONDS);
    }

    cachedHealth.set(Health.up()
    .withDetail(“servers”, bootstrapServers)
    .build());

    } catch (Exception e) {
    cachedHealth.set(Health.down()
    .withDetail(“error”, e.getMessage())
    .build());
    }
    }
    }

    Solr 健康檢查

    @Component
    public class SolrHealthIndicator implements HealthIndicator {

    @Autowired
    private SolrClient solrClient;

    private AtomicReference<Health> cachedHealth =
    new AtomicReference<>(Health.unknown().build());

    @Override
    public Health health() {
    return cachedHealth.get();
    }

    @Scheduled(fixedRate = 30000)
    public void checkHealth() {
    try {
    SolrPingResponse response = solrClient.ping();
    int status = response.getStatus();

    if (status == 0) {
    cachedHealth.set(Health.up()
    .withDetail(“responseTime”, response.getQTime())
    .build());
    } else {
    cachedHealth.set(Health.down()
    .withDetail(“status”, status)
    .build());
    }

    } catch (Exception e) {
    cachedHealth.set(Health.down()
    .withDetail(“error”, e.getMessage())
    .build());
    }
    }
    }


    健康檢查回應範例

    {
    “status”: “UP”,
    “components”: {
    “db”: {
    “status”: “UP”,
    “details”: {
    “database”: “PostgreSQL”,
    “validationQuery”: “isValid()”
    }
    },
    “kafka”: {
    “status”: “UP”,
    “details”: {
    “servers”: “kafka:9092”
    }
    },
    “redis”: {
    “status”: “UP”,
    “details”: {
    “version”: “7.0.0”
    }
    },
    “solr”: {
    “status”: “UP”,
    “details”: {
    “responseTime”: 5
    }
    }
    }
    }

    Kubernetes 整合

    # deployment.yaml
    spec:
    containers:
    – name: oms-service
    # 存活檢查:程式是否還活著
    livenessProbe:
    httpGet:
    path: /health/liveness
    port: 8080
    initialDelaySeconds: 30
    periodSeconds: 10
    timeoutSeconds: 5
    failureThreshold: 3

    # 就緒檢查:是否可以接受流量
    readinessProbe:
    httpGet:
    path: /health/readiness
    port: 8080
    initialDelaySeconds: 20
    periodSeconds: 5
    timeoutSeconds: 3
    failureThreshold: 3

    Probe 類型 失敗後行為 使用場景
    liveness 重啟 Pod 程式死當、無回應
    readiness 從 Service 移除 暫時無法服務(如 DB 斷線)

    設計考量

    為什麼用背景執行緒 + 快取?

    • 健康檢查端點需要快速回應(< 1秒)
    • 外部元件檢查可能很慢(網路延遲)
    • Kubernetes 頻繁呼叫(每 5-10 秒)
    設計 說明
    背景檢查 每 30 秒執行一次,不阻塞端點
    結果快取 AtomicReference 儲存最新狀態
    逾時設定 檢查逾時 5 秒,避免卡住
    狀態詳情 包含時間、錯誤訊息等資訊

    監控整合

    將健康狀態匯出到 Prometheus:

    # 健康狀態指標
    health_check_status{component=”kafka”} 1
    health_check_status{component=”solr”} 1
    health_check_status{component=”redis”} 1
    health_check_status{component=”db”} 1

    # 檢查執行時間
    health_check_duration_seconds{component=”kafka”} 0.023
    health_check_duration_seconds{component=”solr”} 0.005


    總結

    設計 效果
    自定義 HealthIndicator 檢查所有依賴元件
    背景執行 + 快取 端點回應快速
    K8s Probe 整合 自動重啟/移除故障 Pod
    Prometheus 匯出 歷史趨勢監控

    為什麼不用其他方案?

    方案 優點 缺點 結論
    只靠 K8s 預設檢查 零設定 只檢查 HTTP 回應,不知道 DB 狀態 不夠
    外部監控工具打 API 不侵入程式碼 只知道 API 回應,不知道內部狀態 補充用
    自己寫健康檢查 API 完全控制 要自己處理快取、超時 重複造輪子
    Actuator + 自訂 整合好、可擴展 要學 Spring 生態 Spring 專案首選

    實戰踩坑

    坑 1:健康檢查太慢導致 Pod 被殺

    最初健康檢查直接連 Kafka,網路慢時要 10 秒才回應。K8s 以為 Pod 死了,不斷重啟。解法:改成背景執行緒定期檢查,健康端點只回傳快取結果。

    坑 2:Liveness 和 Readiness 混用

    最初兩個 Probe 用同一個端點。結果 Kafka 斷線時,所有 Pod 都被重啟(Liveness 失敗)。正確做法:Liveness 只檢查「程式還活著」,Readiness 檢查「能不能接流量」。Kafka 斷線應該是 Readiness 失敗(從 Service 移除),不是 Liveness 失敗(重啟)。

    坑 3:忘記設定 initialDelaySeconds

    應用程式啟動要 30 秒,但健康檢查 10 秒就開始。結果 Pod 永遠起不來,一直被重啟。


    系列導航

    ◀ 上一篇
    多租戶認證
    📚 返回目錄 下一篇 ▶
    DTO 設計
  • 企業級多租戶認證:Token 驗證實戰

    商業價值:多租戶認證讓「一套系統服務多個商戶」,是 SaaS 模式的技術基礎。這直接支撐 導讀篇提到的 70% 人力成本降低(多商戶共用系統,不用每家都建一套)。

    前言:SaaS 系統的認證挑戰

    在多租戶 SaaS 系統中,我們面臨兩種使用者:

    角色 說明 存取範圍
    商戶 (Merchant) 使用系統的客戶 只能看到自己的資料
    平台 (Platform) 系統管理者 可以看到所有商戶資料
    安全需求:

    • 商戶 A 絕對不能看到商戶 B 的訂單
    • Token 被盜用時要能快速失效
    • 支援多裝置同時登入

    Token 設計

    Token 結構

    {
    “tokenId”: “uuid-xxxx-xxxx”,
    “userType”: “MERCHANT”,
    “userId”: “U001”,
    “merchantId”: “M001”,
    “permissions”: [“ORDER_READ”, “ORDER_WRITE”],
    “issuedAt”: “2024-03-18T10:00:00Z”,
    “expiresAt”: “2024-03-18T12:00:00Z”
    }

    Token 驗證流程

    請求進來


    ┌─────────────────────┐
    │ 1. 檢查 Token 存在 │
    └──────────┬──────────┘


    ┌─────────────────────┐
    │ 2. 檢查 Token 有效 │ ──── 過期/無效 ──→ 401 Unauthorized
    └──────────┬──────────┘


    ┌─────────────────────┐
    │ 3. 載入使用者資訊 │
    └──────────┬──────────┘


    ┌─────────────────────┐
    │ 4. 設定安全上下文 │
    └──────────┬──────────┘


    繼續處理請求

    Filter 實作

    @Component
    public class TokenAuthFilter extends OncePerRequestFilter {

    @Autowired
    private TokenService tokenService;

    @Autowired
    private TokenCache tokenCache;

    @Override
    protected void doFilterInternal(
    HttpServletRequest request,
    HttpServletResponse response,
    FilterChain chain) throws Exception {

    // 1. 從 Header 取得 Token
    String token = extractToken(request);

    if (token == null) {
    sendError(response, “Missing token”);
    return;
    }

    // 2. 驗證 Token(先查快取)
    TokenInfo tokenInfo = tokenCache.get(token);
    if (tokenInfo == null) {
    tokenInfo = tokenService.validate(token);
    if (tokenInfo != null) {
    tokenCache.put(token, tokenInfo);
    }
    }

    if (tokenInfo == null || tokenInfo.isExpired()) {
    sendError(response, “Invalid or expired token”);
    return;
    }

    // 3. 設定安全上下文
    SecurityContext.set(tokenInfo);

    try {
    chain.doFilter(request, response);
    } finally {
    SecurityContext.clear();
    }
    }

    private String extractToken(HttpServletRequest request) {
    String header = request.getHeader(“Authorization”);
    if (header != null && header.startsWith(“Bearer “)) {
    return header.substring(7);
    }
    return null;
    }
    }


    商戶隔離

    所有資料存取都要加上商戶過濾:

    @Repository
    public class OrderRepository {

    public List<Order> findOrders(OrderQuery query) {
    // 從安全上下文取得當前商戶
    TokenInfo token = SecurityContext.get();

    if (token.isMerchant()) {
    // 商戶只能查自己的資料
    query.setMerchantId(token.getMerchantId());
    }
    // 平台角色可以查所有商戶(不加過濾條件)

    return jdbcTemplate.query(
    “SELECT * FROM orders WHERE merchant_id = ? AND …”,
    query.getMerchantId(),

    );
    }
    }

    安全保證:即使前端傳入其他商戶 ID,後端也會強制覆蓋為當前登入商戶的 ID。

    Token 快取策略

    策略 設定 原因
    快取時間 5 分鐘 減少 DB 查詢,但不會太舊
    最大數量 10,000 避免記憶體爆炸
    淘汰策略 LRU 不常用的 Token 先移除
    @Bean
    public Cache<String, TokenInfo> tokenCache() {
    return Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(5))
    .recordStats()
    .build();
    }

    Token 失效機制

    當需要強制登出時:

    @Service
    public class TokenService {

    /**
    * 強制失效單一 Token
    */

    public void revokeToken(String tokenId) {
    // 1. 從資料庫標記失效
    tokenRepository.markRevoked(tokenId);

    // 2. 從快取移除
    tokenCache.invalidate(tokenId);

    // 3. 發布失效事件(通知其他節點)
    eventPublisher.publish(new TokenRevokedEvent(tokenId));
    }

    /**
    * 強制登出商戶所有 Token
    */

    public void revokeAllTokens(String merchantId) {
    List<String> tokens = tokenRepository.findByMerchant(merchantId);
    tokens.forEach(this::revokeToken);
    }
    }


    權限檢查

    @RestController
    public class OrderController {

    // 需要 ORDER_READ 權限
    @RequirePermission(“ORDER_READ”)
    @GetMapping(“/api/orders”)
    public List<Order> listOrders() {
    return orderService.findOrders();
    }

    // 需要 ORDER_WRITE 權限
    @RequirePermission(“ORDER_WRITE”)
    @PostMapping(“/api/orders/{id}/ship”)
    public Order shipOrder(@PathVariable String id) {
    return orderService.ship(id);
    }
    }


    安全總結

    安全機制 實作方式 防護目標
    Token 驗證 Filter 攔截 未授權存取
    商戶隔離 強制覆蓋 merchantId 資料外洩
    Token 快取 Caffeine + 分散式同步 效能
    強制失效 事件發布 + 快取清除 帳號被盜
    權限檢查 註解 + AOP 越權操作

    為什麼不用其他方案?

    方案 優點 缺點 結論
    Session-based 簡單、狀態伺服器端管理 不適合分散式、需要 Session 複製 單機可用
    JWT(無狀態) 不需查 DB、自包含 無法主動失效、Token 可能很大 短期 Token 可用
    OAuth 2.0 標準協議、支援第三方 複雜、需要額外服務 需要 SSO 時用
    Token + 快取 可主動失效、效能好 需要 Redis SaaS 系統首選

    實戰踩坑

    坑 1:商戶資料外洩

    早期版本有個 API 忘記加商戶過濾,某商戶發現可以透過改 URL 的 orderId 看到別人的訂單。好險被內部測試發現,立刻修復並全面檢查所有 API。教訓:所有 Repository 方法預設都要加 merchantId 過濾

    坑 2:Token 快取同步問題

    三台 Server,使用者在 A 機登出,但 B、C 機的快取還有 Token。結果登出後還能繼續操作 30 秒。解法:登出時發布事件到所有節點,同步清除快取。

    坑 3:權限設計太細

    最初設計了 50+ 種權限,結果商戶搞不懂、客服也解釋不清。後來簡化成 5 個角色(管理員、主管、操作員、財務、唯讀),大幅降低維護成本。


    系列導航

    ◀ 上一篇
    Kafka 事件驅動
    📚 返回目錄 下一篇 ▶
    健康檢查
  • Kafka 事件驅動架構:打造高可用訂單處理系統

    商業價值:事件驅動架構讓系統能「處理速度提升 10 倍」,從 4-8 小時縮短到 25-35 分鐘。詳見 導讀篇的 ROI 計算

    前言:為什麼需要事件驅動?

    想像一個場景:使用者在後台點擊「同步蝦皮訂單」。

    同步處理的問題:

    • 蝦皮 API 回應慢 → 使用者等待 30 秒以上
    • API 超時 → 整個請求失敗
    • 大量請求 → 伺服器資源耗盡

    解決方案:非同步事件驅動

    使用者請求 背景處理
    │ │
    ▼ │
    ┌─────────┐ 發送訊息 ┌─────────┐
    │ Web API │ ──────────────► │ Kafka │
    └─────────┘ └────┬────┘
    │ │
    ▼ ▼
    回應成功 ┌───────────────┐
    (立即返回) │ Consumer Job │
    │ 慢慢處理… │
    └───────────────┘
    效果:使用者立即收到「已排程」回應,實際同步在背景執行。

    架構設計

    Topic 設計:每個通路獨立

    Topic 名稱 用途 Consumer
    oms-action-shopee 蝦皮相關動作 Shopee Consumer
    oms-action-momo Momo 相關動作 Momo Consumer
    oms-action-yahoo Yahoo 相關動作 Yahoo Consumer
    oms-action-pchome PChome 相關動作 PChome Consumer
    為什麼要分開?

    • 蝦皮 API 壞了,不影響 Momo 訂單處理
    • 可以針對不同平台調整 Consumer 數量
    • 方便監控各平台的處理狀況

    Producer:發送訊息

    @Service
    public class ActionProducer {

    private final KafkaTemplate<String, String> kafkaTemplate;

    /**
    * 發送動作到對應的通路 Topic
    */

    public void sendAction(ChannelType channel, ActionMessage message) {
    String topic = “oms-action-“ + channel.getCode();
    String payload = JsonUtil.toJson(message);

    kafkaTemplate.send(topic, message.getMerchantId(), payload)
    .addCallback(
    result -> log.info(“發送成功: {}”, topic),
    error -> log.error(“發送失敗: {}”, error.getMessage())
    );
    }
    }

    訊息格式設計

    {
    “header”: {
    “messageId”: “uuid-xxxx-xxxx”,
    “timestamp”: “2024-03-18T10:30:00Z”,
    “traceId”: “trace-xxxx”
    },
    “body”: {
    “merchantId”: “M001”,
    “actionType”: “SYNC_ORDERS”,
    “parameters”: {
    “startDate”: “2024-03-17”,
    “endDate”: “2024-03-18”
    }
    }
    }

    Consumer:處理訊息

    @Component
    public class ActionConsumer {

    @Autowired
    private ChannelFactory channelFactory;

    @KafkaListener(topics = “oms-action-shopee”)
    public void consumeShopee(String message) {
    processAction(ChannelType.SHOPEE, message);
    }

    @KafkaListener(topics = “oms-action-momo”)
    public void consumeMomo(String message) {
    processAction(ChannelType.MOMO, message);
    }

    private void processAction(ChannelType channel, String message) {
    try {
    // 1. 解析訊息
    ActionMessage action = JsonUtil.fromJson(message);

    // 2. 取得對應的通路處理器
    ChannelAction handler = channelFactory.getAction(channel);

    // 3. 執行動作
    ActionResult result = handler.execute(action);

    // 4. 回寫結果
    saveResult(action, result);

    } catch (Exception e) {
    // 5. 錯誤處理
    handleError(message, e);
    }
    }
    }


    錯誤處理策略

    錯誤類型 處理方式 範例
    暫時性錯誤 重試 3 次 API 超時、網路問題
    永久性錯誤 記錄並跳過 資料格式錯誤
    未知錯誤 進入 Dead Letter Queue 系統異常
    @Bean
    public DefaultErrorHandler errorHandler() {
    // 設定重試策略
    BackOff backOff = new ExponentialBackOff(1000L, 2.0);
    backOff.setMaxElapsedTime(30000L); // 最多重試 30 秒

    return new DefaultErrorHandler(
    (record, exception) -> {
    // 重試失敗後,送到 Dead Letter Queue
    sendToDeadLetterQueue(record, exception);
    },
    backOff
    );
    }


    監控與告警

    監控指標 正常值 告警條件
    Consumer Lag < 1000 > 5000 持續 5 分鐘
    處理時間 < 5 秒 > 30 秒
    錯誤率 < 1% > 5%
    Dead Letter 數量 0 > 10

    效能調校

    # application.yml
    spring:
    kafka:
    consumer:
    # 每次拉取的最大筆數
    max-poll-records: 100

    # 拉取間隔
    fetch-min-size: 1
    fetch-max-wait: 500ms

    producer:
    # 批次發送設定
    batch-size: 16384
    buffer-memory: 33554432

    # 壓縮
    compression-type: lz4


    總結

    設計 效果
    非同步處理 使用者不用等待 API 回應
    Topic 分離 通路故障隔離
    重試機制 暫時性錯誤自動恢復
    Dead Letter Queue 問題訊息不遺失
    監控告警 問題即時發現

    為什麼不用其他方案?

    方案 優點 缺點 結論
    同步處理 簡單、好除錯 使用者要等、效能差 小流量可用
    Redis Queue 輕量、快速 持久化弱、無法分區 簡單場景可用
    RabbitMQ 功能豐富、可靠 吞吐量不如 Kafka 適合複雜路由
    Kafka 高吞吐、持久化、分區 學習曲線、維運成本 大流量首選

    實戰踩坑

    坑 1:Consumer Lag 暴增

    雙 11 當天 Consumer Lag 飆到 50,000+,訂單處理延遲 2 小時。原因:單一 Consumer 處理太慢。解法:增加 Consumer 數量到 Partition 數量,同時優化處理邏輯(批次處理)。

    坑 2:訊息重複消費

    Consumer 處理到一半掛掉,重啟後同一筆訂單被處理兩次,導致重複出貨。解法:加入冪等性檢查(用訂單 ID 去重)。

    坑 3:Topic 沒分開

    最初所有平台共用一個 Topic,蝦皮 API 壞了堵住整條 Queue,Momo 訂單也跟著延遲。後來拆成每個平台獨立 Topic,故障隔離。


    系列導航

    ◀ 上一篇
    工廠模式
    📚 返回目錄 下一篇 ▶
    多租戶認證