分類: 🛒 電商 OMS 系統

  • 10 年舊系統如何安全導入 AI 開發:Strangler Fig 遷移方法論

    重點摘要

    • 10 年的舊系統能跑就是最有價值的資產,不要試圖先修好再遷移
    • 核心方法論:Strangler Fig 模式 — 新軌道在旁邊長起來,舊系統自然退場
    • AI 第一件事不是寫 code,而是讀懂 10 年的系統邏輯,再動手
    • 四個階段:快照現況 → 建平行新軌 → 一次搬一個服務 → 封存舊系統

    你的系統跑了 10 年。它很髒、沒有文件、CI/CD 靠手動、密碼可能在 .env 裡或者在某個工程師的腦袋裡。但它能跑,而且在服務真實的用戶。

    現在你想引入 AI 輔助開發,想現代化整個工作流。問題來了:要從哪裡開始? 要先把舊的修乾淨,還是直接用新方法?

    錯誤的答案是:「先把舊的修好。」正確的答案是:不要動正在跑的東西,在旁邊建一條新軌道。

    為什麼「先修好再用新方法」行不通?

    這個直覺很自然,但在實際工程上幾乎都會失敗,原因有三:

    1. 無法停止開發等你修 — 業務不會暫停,新需求還是會進來,你邊修邊開發,舊問題永遠追不完
    2. 「修好」的定義會不斷移動 — 一開始說只要加 .gitignore,結果發現歷史有密碼,要 filter-repo,然後發現測試覆蓋率是零…沒有終點
    3. 你在修一個不完全理解的系統 — 10 年的系統有太多隱性知識,修的過程中很容易把「能跑的」改成「不能跑的」

    工程界有一個著名的模式專門解決這個問題,叫做 Strangler Fig(絞殺榕)模式

    Strangler Fig 模式:不砍舊樹,讓新藤蔓長過去

    絞殺榕是一種熱帶植物。它的種子落在老樹上,慢慢向下長出根,包住舊樹,最後舊樹自然退場,絞殺榕站立在原位。整個過程中,舊樹從未停止「提供支撐」,直到新系統完全就緒。

    應用到 DevOps 遷移:

    ❌ 錯誤思維:
    舊系統(停機)→ 修好 → 接新流程 → 恢復服務
    
    ✅ 正確思維(Strangler Fig):
    舊系統(持續運行,不動)
        ↓
    新軌道在旁邊建立(不影響舊系統)
        ↓
    一次搬一個服務,驗證後切換流量
        ↓
    所有服務搬完,舊系統自然退場

    關鍵洞察:能跑的系統是你最有價值的資產,不是問題的來源。遷移的目標是「讓它繼續跑,同時讓新系統在旁邊成長」,不是「讓它停下來修好再說」。

    四個階段的完整遷移方法論

    階段一:快照現況(不動任何東西)

    第一步不是改 code,不是設定 CI/CD,而是把「現在是怎麼跑起來的」完整記錄下來。這份快照是整個遷移過程的地基。

    為什麼要快照?因為在 10 年的系統裡,repo 裡的 .env 可能是舊的,文件可能是錯的,只有正在跑的進程才是真相:

    # 從正在跑的容器抽出真實的環境變數
    docker inspect <container_name> \
      --format='{{range .Config.Env}}{{println .}}{{end}}' \
      > /tmp/real-env-snapshot.txt
    
    # 或直接讀進程的環境變數
    cat /proc/$(pgrep java)/environ | tr '\0' '\n' | grep -E "DB_|API_|SECRET_"
    
    # K8s 環境
    kubectl get pods -n production -o name | while read pod; do
      echo "=== $pod ==="
      kubectl exec $pod -n production -- env 2>/dev/null
    done > /tmp/real-k8s-env-snapshot.txt

    同時盤點服務清單和依賴關係:

    # 有哪些服務在跑
    docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
    
    # 服務之間怎麼通訊
    docker network inspect bridge
    
    # 對外開放哪些 port
    ss -tlnp | grep LISTEN

    這個階段的產出是一份真實架構圖和一份真實密碼清單(妥善保管,不進任何 repo)。

    階段二:AI 讀懂你的系統

    這是大多數人忽略的步驟,也是決定 AI 協作能否成功的關鍵。

    AI 第一件事不是寫 code。

    在 AI 動手之前,它需要讀你 10 年的系統。這個過程大概需要幾天,但會產出你可能從未有過的東西:

    AI 讀完後的產出 為什麼重要
    系統架構圖 你們可能自己也沒有,新人上手和遷移規劃的基礎
    模組依賴關係 知道改哪個地方會影響哪些服務
    高風險區域標記 「這段 code 10 個人改過,有 3 個已知 bug 的修復」
    技術債清單(按影響排序) 知道先解決什麼,不是看到髒的就改
    新人上手文件 從 code 反推出來,不需要老工程師口傳

    這個階段同樣不動任何 code。AI 只是閱讀和理解。等到真正開始寫新功能時,AI 已經知道你的系統慣例是什麼、有哪些地雷不能踩。

    階段三:建立平行的新軌道

    新建一個乾淨的 repo,在旁邊建立完整的現代化工作流,舊 repo 繼續照舊運作

    舊 GitLab repo(繼續跑,不動)
         │
         │  正在服務用戶的系統
         │
    新 repo(乾淨起點)
         │
         ├─ 正確的 .gitignore(.env 全部排除)
         ├─ CI/CD pipeline(gitleaks + build + sign)
         ├─ K8s Secrets(從快照搬進來)
         └─ Branch Protection Rules

    把真實密碼從快照搬到 K8s Secrets(不是從舊 repo 搬,是從正在跑的進程抽出來):

    # 從快照建立 K8s Secrets
    kubectl create secret generic app-prod-creds \
      --from-literal=DB_PASSWORD="$(grep DB_PASSWORD /tmp/real-env-snapshot.txt | cut -d= -f2)" \
      --from-literal=SHOPEE_KEY="$(grep SHOPEE_KEY /tmp/real-env-snapshot.txt | cut -d= -f2)" \
      -n production
    
    # 建立完成後,安全刪除快照
    shred -u /tmp/real-env-snapshot.txt

    階段四:一次搬一個服務

    這是遷移的主體。原則是:每次只搬一個服務,驗證通過才搬下一個

    服務搬移的優先順序建議:

    優先順序 選擇原則 理由
    第一批 流量最小的非核心服務 風險最低,可以放心試錯
    第二批 獨立性高、依賴少的服務 不會牽一髮動全身
    最後 核心業務邏輯(訂單、付款) 等前幾批證明新流程可靠後再動

    每個服務的搬移步驟:

    1. AI 在新 repo 重寫該服務的乾淨版本(理解舊 code 後重寫,不是 copy paste)
    2. Jenkins 構建新鏡像,部署到 Staging
    3. 用影子流量驗證新舊行為一致(新舊同時收請求,比對回應)
    4. 確認無誤,切換這個服務的流量到新系統
    5. 觀察 24-48 小時
    6. 舊服務下線

    影子流量驗證(用 nginx 實現,不影響用戶):

    # nginx 配置:新舊同時收請求,比對行為
    location /api/orders {
        mirror /mirror-new;       # 複製請求到新系統
        proxy_pass http://old-api; # 用戶實際收到舊系統的回應
    }
    
    location = /mirror-new {
        internal;
        proxy_pass http://new-api;  # 新系統的回應只寫 log,不回傳用戶
    }

    AI 在整個過程中的實際角色

    理解了遷移方法論後,AI 在這個過程中的職責非常清楚:

    AI 能做(且做得很好):
      ✅ 讀 10 年的舊 code,產出架構文件
      ✅ 識別技術債,按優先順序排列
      ✅ 用「符合你們系統慣例」的方式寫新功能
      ✅ 在 feature branch 開發,開 MR,等你 review
      ✅ 補測試(補現有 code 的測試覆蓋率)
      ✅ Debug(分析 log,找根因)
      ✅ 在新服務和舊系統的接縫處寫 adapter
    
    AI 不能做(也不應該做):
      ❌ 直接 push 到 main 或 production branch
      ❌ 決定什麼時候切換流量
      ❌ 接觸任何密碼或 production secrets
      ❌ 在沒有充分理解舊系統的情況下動手

    一個典型的 AI 協作對話長這樣:

    你:「我要加一個批次出貨的功能」
    
    AI:「讓我先讀一下現有的出貨相關 code...
         我看到 OrderService.java 的 shipOrder()
         和 ShipmentRepository,還有一個舊的
         BatchJob 在 legacy/ 目錄。
    
         注意:legacy/BatchJob 有個已知的
         transaction 問題(commit a3f2c1b 的修復紀錄),
         新功能要避開。
    
         建議的實作方向是...你確認嗎?」
    
    你:「方向對,去做吧」
    
    AI:「好,我開 feature/batch-shipment,
         預計改動:
         - 新增 BatchShipmentService(核心邏輯)
         - 修改 OrderController(加 API endpoint)
         - 不動 legacy/ 目錄
         
         完成後開 MR 給你 review」

    AI 因為讀過 10 年的系統,不會做出「不符合你們慣例」的 code,也不會踩進已知的地雷。

    最常遇到的三個現實問題

    問題一:「不知道哪個 .env 是現在真正在用的」

    10 年的系統通常有多個 .env 版本,有的是舊的,有的是工程師自己改過的。以正在跑的進程為準,不要相信文件

    # 找到 Java 進程真正使用的環境變數
    cat /proc/$(pgrep java)/environ | tr '\0' '\n' | grep -E "DB_|API_|SECRET_"
    
    # 不要用
    cat .env  # 這可能是 6 個月前的版本

    問題二:「團隊還在開發,不能凍結」

    不需要凍結。舊 repo 繼續用,新 repo 並行開發。重要的 bugfix 透過 cherry-pick 同步:

    舊 repo: feature → dev → main(繼續照舊)
                  │
                  └─ cherry-pick 重要修復
                          │
    新 repo:              └─ feature → dev → main(新流程)

    問題三:「歷史 commit 有密碼怎麼辦」

    分兩步處理:

    1. 立即輪換所有洩露的密碼 — 因為 GitHub/GitLab 可能已有快取,這一步不能等
    2. 舊 repo 等到遷移完成後再 archive — 不需要現在重寫歷史,新 repo 一開始就是乾淨的

    注意:很多人以為 git rm --cached .env 就安全了,但舊 commit 裡的內容仍然可以被 git show <old-commit>:.env 讀出。唯一的技術修復是 git filter-repo,但遷移方法論讓你可以跳過這一步——因為所有新開發都在新的乾淨 repo 上進行。

    時間軸規劃

    時間 工作 風險
    第 1 週 快照現況、輪換外部 API Key、AI 開始讀系統 零(不動任何 code)
    第 2-4 週 建新 repo、CI/CD pipeline、K8s Secrets、第一個服務搬過去 低(影子流量驗證)
    第 1-3 個月 逐服務遷移,每個驗證 24-48 小時後才繼續 中(每次只影響一個服務)
    遷移完成後 舊 repo archive、完整安全強化(RBAC、Audit Log、鏡像簽名) 低(新系統已穩定)

    決策樹:你現在該從哪裡開始

    你的系統現在能跑嗎?
      │
      ├─ 能跑 → 用 Strangler Fig 模式(本文的方法)
      │           │
      │           ├─ 步驟 1:快照現況(本週就做)
      │           ├─ 步驟 2:AI 讀系統(同步進行)
      │           ├─ 步驟 3:建新軌道(第 2-4 週)
      │           └─ 步驟 4:逐服務遷移(之後)
      │
      └─ 不能跑 → 先讓它跑起來,再回到這裡

    這套方法論的本質

    Strangler Fig 模式應用在 AI 輔助開發遷移上,核心洞察只有一個:

    「10 年的技術債是過去的決策的結果,你無法在不破壞現有價值的情況下一次消除它。但你可以選擇:從今天開始,所有新的工作都用正確的方式做。」

    舊系統是你團隊的集體記憶,AI 有能力閱讀並理解這些記憶,然後用現代化的方式繼續往前走。不需要推倒重建,也不需要凍結開發去修舊債——只需要一條平行的新軌道,和耐心地一次移動一個服務。

    想了解新軌道的 CI/CD 具體設計,可以參考上一篇文章:AI 輔助開發 CI/CD 工作流:Jenkins、K8s、ISO 27001 完整設計

  • AI 輔助開發 CI/CD 工作流:Jenkins、K8s、ISO 27001 完整設計

    重點摘要

    • AI 只負責寫 code、提 PR,不碰版本決策和 Production 部署,人類保留最終控制權
    • 透過 Git tag 觸發 Jenkins,Staging 全自動部署、Production 手動 helm 執行,兩階段驗證才上線
    • 敏感資訊三層隔離:.gitignore → K8s Secrets → etcd 加密,密碼永遠不進 repo
    • 補齊 RBAC、Audit Log、鏡像簽名、Secrets Scan 四大安全缺口,達到 ISO 27001 合規

    AI 輔助開發越來越普遍,但大多數團隊面臨同一個問題:AI 寫的 code 要怎麼安全地上線? 誰決定部署時機?密碼怎麼管?如果 AI 出錯了,有什麼防護網?

    本篇文章完整說明 ONEEC OMS 系統實際採用的 AI 協作工作流設計,包含完整的 User Story、Jenkins Pipeline 架構、三環境部署策略,以及通過安全審查後補齊的 RBAC、Audit Log、鏡像簽名等安全強化配置。

    核心設計理念:人類掌控節奏,AI 加速執行

    這套工作流的核心原則只有一句話:AI 是高效能的執行者,不是決策者。具體體現在以下四點:

    • AI 負責:寫 code、建 Dockerfile、提 PR、提供 Jenkins script 和 Helm chart
    • 用戶負責:code review、創建 git tag(決定版本和部署時機)、手動 helm 部署到 Production
    • 運維負責:管理 K8s Secrets、設定 Jenkins credentials、維護集群
    • 敏感資訊:密碼、API Key、SSL 憑證永遠不進入 Git repo

    完整 User Story:從需求到上線的 10 個步驟

    以下用一個真實場景說明整個流程:場景:優化訂單 API 的查詢效能

    Step 1:AI 開發(feature branch)

    AI 從 dev 分支切出 feature branch,完成開發後推送 PR:

    # AI 執行
    git checkout dev && git pull origin dev
    git checkout -b feature/order-api-optimize
    
    # 編寫程式碼...
    
    # 本地驗證
    docker-compose up -d
    curl http://localhost:8080/api/orders?status=pending
    # ✅ 回傳正確,效能提升 30%
    
    # 提交並推送
    git add . && git commit -m "feat(order-api): optimize query performance"
    git push origin feature/order-api-optimize
    # 建立 PR → dev

    Step 2:用戶 Code Review & Merge

    用戶在 GitHub UI 審查 PR:確認邏輯正確、有測試覆蓋、無敏感資訊後 approve 並 merge 到 dev。此時沒有任何自動化觸發,代碼靜靜等待部署決策。

    Step 3:用戶創建 Staging Tag → Jenkins 自動觸發

    用戶決定要部署到測試環境時,創建一個 staging-v* tag:

    # 用戶執行
    git tag staging-v1.0.1
    git push origin staging-v1.0.1
    
    # GitHub Webhook → Jenkins 自動執行:
    # ├─ Secrets 掃描(gitleaks)
    # ├─ docker build(所有 pods)
    # ├─ cosign 簽名鏡像
    # ├─ docker push to registry
    # ├─ helm deploy to Staging K8s(使用 values-staging.yaml)
    # └─ 通知用戶:Staging v1.0.1 is live

    Step 4:用戶在 Staging 驗證

    kubectl get pods -n staging
    curl https://staging-api.example.com/api/orders?status=pending
    # ✅ 功能正常,效能優化生效
    # ✅ 錯誤率 0%
    # ✅ 回應時間 < 100ms

    Step 5:用戶創建 Production Tag → Jenkins 構建正式鏡像

    # 用戶執行(確認 Staging 無誤後)
    git tag v1.0.1
    git push origin v1.0.1
    
    # Jenkins 執行:
    # ├─ Secrets 掃描
    # ├─ docker build(所有 pods,tag 改為 v1.0.1)
    # ├─ cosign 簽名鏡像
    # ├─ docker push to registry
    # ├─ 生成 Helm values(不含敏感資訊)
    # └─ 通知用戶:Images ready, run helm command

    Step 6:用戶手動部署到 Production

    Production 部署是整個流程中唯一純手動的步驟,這是刻意設計的——確保每一次正式上線都有人類判斷:

    # 用戶在本機執行
    helm upgrade --install order-api \
      /path/to/your/prod-configs/order-api/values-prod.yaml \
      --set image.tag=v1.0.1 \
      -n production
    
    # K8s 自動從 Secrets 注入密碼、API Key
    # Kyverno 自動驗證鏡像簽名(未簽名直接拒絕)
    # Deployment 完成 ✅

    Step 7:監控確認上線成功

    kubectl get pods -n production
    curl https://api.example.com/api/orders?status=pending
    # ✅ 正式環境驗證通過,上線成功

    三個部署環境的定義與分工

    環境 用途 部署方式 配置來源 觸發者
    Dev 本地開發驗證 docker-compose up .env.dev AI(開發時)
    Staging 測試環境(K8s) Jenkins 自動部署 values-staging.yaml(在 repo) 用戶(tag 觸發)
    Production 正式環境(K8s) 手動 helm 部署 values-prod.yaml(用戶維護)+ K8s Secrets 用戶(手動執行)

    Jenkins Pipeline 完整架構

    Jenkins Pipeline 由 GitHub Webhook(tag push)觸發,整個流程分為 6 個 Stage:

    Stage 0:Secrets 掃描(安全門控)

    這是整個 Pipeline 的第一道防線,也是最重要的安全門控。使用 gitleaks 掃描 repo 中是否含有密碼、API Key 等敏感資訊,發現即中止構建並通知安全告警

    stage('Secrets Scan') {
        steps {
            sh '''
                gitleaks detect \
                  --source . \
                  --config .gitleaks.toml \
                  --exit-code 1 \
                  --report-format json \
                  --report-path gitleaks-report.json
            '''
        }
        post {
            failure {
                sh 'sh scripts/notify-security-alert.sh ${TAG_NAME} gitleaks-report.json'
                error('❌ Secrets 掃描發現敏感資訊,構建中止!')
            }
        }
    }

    Stage 1:Tag 偵測(決定部署目標)

    根據 tag 名稱判斷本次構建的部署目標:

    stage('Detect Tag') {
        steps {
            script {
                if (env.TAG_NAME =~ /^staging-v.*/) {
                    env.DEPLOYMENT_ENV = 'staging'
                } else if (env.TAG_NAME =~ /^v.*/) {
                    env.DEPLOYMENT_ENV = 'production'
                } else {
                    error("❌ 未知 tag 格式: ${env.TAG_NAME}")
                }
            }
        }
    }

    Stage 2:Build Images

    構建所有 Pod 的 Docker 鏡像。鏡像本身不含任何配置、密碼、API Key,這是配置與代碼分離的核心原則:

    #!/bin/bash
    # scripts/build-docker.sh
    TAG=$1
    
    docker build -t registry.example.com/order-api:${TAG} ./simpleec-api
    docker build -t registry.example.com/user-app:${TAG} ./user-app
    docker build -t registry.example.com/channel-job:${TAG} ./simpleec-channel-job
    # ... 所有 pods

    Stage 3:Sign Images(供應鏈安全)

    使用 cosign 為每個鏡像簽名,確保 Production 只能部署來自 Jenkins 的受信任鏡像:

    stage('Sign Images') {
        steps {
            withCredentials([file(credentialsId: 'cosign-private-key', variable: 'COSIGN_KEY')]) {
                sh 'sh scripts/sign-docker.sh ${TAG_NAME} ${COSIGN_KEY}'
            }
        }
    }
    
    # scripts/sign-docker.sh
    for IMAGE in "${IMAGES[@]}"; do
        cosign sign --key "${COSIGN_KEY}" \
          --tlog-upload=false \
          "${IMAGE}"
    done

    Stage 4:Push Images

    推送到 Docker Registry。Registry 啟用 Immutable Tags,同一個 tag 無法被覆蓋,確保版本不可篡改:

    stage('Push Images') {
        steps {
            withCredentials([usernamePassword(
                credentialsId: 'docker-registry-creds',
                usernameVariable: 'REGISTRY_USER',
                passwordVariable: 'REGISTRY_PASS'
            )]) {
                sh 'sh scripts/push-docker.sh ${TAG_NAME}'
            }
        }
    }

    Stage 5a(Staging):自動部署到 Staging K8s

    stage('Deploy to Staging') {
        when { expression { env.DEPLOYMENT_ENV == 'staging' } }
        steps {
            withCredentials([file(credentialsId: 'kubeconfig-staging', variable: 'KUBECONFIG')]) {
                sh '''
                    helm upgrade --install order-api ./k8s/helm/order-api \
                      --values ./k8s/helm/order-api/values-staging.yaml \
                      --set image.tag=${TAG_NAME} \
                      -n staging
                '''
            }
        }
    }

    Stage 5b(Production):生成 Helm Values,通知用戶手動部署

    對於 Production tag,Jenkins 不自動部署,而是生成配置檔並通知用戶手動執行:

    stage('Generate Helm Values') {
        when { expression { env.DEPLOYMENT_ENV == 'production' } }
        steps {
            sh 'sh scripts/generate-helm-values.sh ${TAG_NAME}'
            // 生成 values-v${TAG_NAME}.yaml(不含敏感資訊)
            // 通知用戶:Images ready, run helm command
        }
    }

    Helm 配置隔離:敏感資訊三層防護

    配置分為三層,層層隔離:

    第一層:values-staging.yaml(在 repo,測試配置)

    # 主機名用占位符,從 Jenkins 環境變數注入,不硬編碼內網地址
    env:
      DATABASE_HOST: "${POSTGRES_STAGING_HOST}"
      DATABASE_NAME: simpleec_test
      REDIS_HOST: "${REDIS_STAGING_HOST}"
      API_LOG_LEVEL: DEBUG

    第二層:values-prod.yaml(用戶本機維護,不進 repo)

    # 用戶的私密文件,只在本機
    env:
      DATABASE_HOST: postgres-prod.example.com
      API_LOG_LEVEL: WARN
      # ⚠️ 資料庫密碼不在這裡!從 K8s Secrets 注入
    
    envFrom:
      - secretRef:
          name: database-prod-creds  # K8s Secret(運維管理)
      - secretRef:
          name: api-keys-prod        # K8s Secret(運維管理)

    第三層:K8s Secrets + etcd 加密

    # 運維在 Production K8s 上創建
    kubectl create secret generic database-prod-creds \
      --from-literal=username=prod_user \
      --from-literal=password=<secure-password> \
      -n production
    
    # K8s 預設 Secrets 以 base64 存在 etcd(並非加密!)
    # 必須啟用 encryption at rest
    # /etc/kubernetes/encryption-config.yaml
    apiVersion: apiserver.config.k8s.io/v1
    kind: EncryptionConfiguration
    resources:
      - resources: ["secrets"]
        providers:
          - aescbc:
              keys:
                - name: key1
                  secret: <base64-encoded-32-byte-key>

    安全強化:補齊四大缺口

    原始設計經過安全審查後,發現四個必須在投產前補足的缺口:

    缺口一:K8s RBAC 未定義

    三個角色各有最小權限(文件放在 k8s/rbac/):

    角色 允許操作 明確禁止
    Jenkins SA(staging) update/patch Deployments, get Pods 讀取任何 Secrets
    用戶(production) helm 部署相關資源 讀取業務 Secrets(DB 密碼、API Key)
    運維(production) Secrets 完整管理權
    # 驗證 Jenkins SA 無法讀取 Secrets(應輸出 no)
    kubectl auth can-i get secrets \
      --as=system:serviceaccount:staging:jenkins-deployer \
      -n staging

    缺口二:K8s Audit Log 未配置

    ISO 27001 A.12.4.1 要求所有敏感操作都要有日誌。以下 Audit Policy 至少記錄 Secrets 訪問和 Deployment 變更:

    # /etc/kubernetes/audit-policy.yaml
    apiVersion: audit.k8s.io/v1
    kind: Policy
    rules:
      - level: Metadata
        resources:
          - group: ""
            resources: ["secrets"]  # 所有 Secrets 訪問都記錄
    
      - level: Request
        verbs: ["create", "update", "delete", "patch"]
        resources:
          - group: "apps"
            resources: ["deployments"]
    
      - level: None
        users: ["system:kube-proxy"]
        verbs: ["watch", "list"]

    缺口三:鏡像簽名驗證(Kyverno 準入控制)

    確保集群只能部署來自 Jenkins 簽名的鏡像,防止鏡像替換攻擊:

    apiVersion: kyverno.io/v1
    kind: ClusterPolicy
    metadata:
      name: verify-image-signatures
    spec:
      validationFailureAction: Enforce  # 未簽名鏡像直接拒絕
      rules:
        - name: check-image-signature
          match:
            any:
              - resources:
                  kinds: ["Pod"]
                  namespaces: ["staging", "production"]
          verifyImages:
            - imageReferences:
                - "registry.example.com/*"
              attestors:
                - count: 1
                  entries:
                    - keys:
                        publicKeys: |-
                          -----BEGIN PUBLIC KEY-----
                          # cosign.pub 內容
                          -----END PUBLIC KEY-----

    缺口四:GitHub Branch Protection 口頭約定 → 技術強制

    分支 Required Reviews CI 必須通過 Push 限制
    main 2 人 approve ✅ jenkins-build + secrets-scan 僅 team-lead
    staging 1 人 approve ✅ jenkins-build + secrets-scan 僅 team-lead
    dev 1 人 approve 必須透過 PR(AI 不能直接 push)

    Git 分支策略與 Tag 命名規範

    整個工作流的分支拓撲如下:

    main                     # Production 對應,受嚴格保護
     └─ tag: v1.0.0, v1.0.1  # 觸發 Jenkins 構建 Production 鏡像
    
    staging                  # 測試環境,中度保護
     └─ tag: staging-v1.0.0  # 觸發 Jenkins 自動部署到 Staging K8s
    
    dev                      # 開發積累,AI 透過 PR 提交
     └─ 來源:feature/* 合入
    
    feature/*                # AI 的工作分支(每個功能一個)
     ├─ feature/user-auth
     ├─ feature/order-api
     └─ feature/channel-job-momo

    敏感資訊完整隔離架構

    存放位置 可以存什麼 絕對不能存什麼 管理者
    Git Repository 代碼、Dockerfile、values-staging.yaml、Helm chart 模板 密碼、API Key、SSL 憑證、values-prod.yaml AI + 用戶
    Docker Registry 不含配置的乾淨鏡像(cosign 簽名) 任何敏感資訊 Jenkins(push)
    K8s Secrets(etcd 加密) database-prod-creds、api-keys-prod、SSL 憑證 運維
    Jenkins Credentials GitHub token、Registry credentials、cosign key、kubeconfig 運維

    回滾策略

    Staging 環境回滾

    # 快速回滾到上一個版本
    helm rollback order-api 0 -n staging
    
    # 或指定版本
    helm upgrade order-api ./k8s/helm/order-api \
      --values ./k8s/helm/order-api/values-staging.yaml \
      --set image.tag=staging-v1.0.0 \
      -n staging

    Production 環境回滾

    # 查看部署歷史
    helm history order-api -n production
    
    # 回滾到上一個版本
    helm rollback order-api 0 -n production
    
    # 所有 tag 在 Git 可追溯
    git log --oneline --all | grep "v1.0"

    投產前安全檢查清單

    在正式上線前,以下所有項目必須確認通過:

    代碼倉庫安全

    • ✅ .gitignore 包含 .env, .env.dev, **/values-prod.yaml
    • ✅ repo 根目錄存在 .gitleaks.toml 配置文件
    • ✅ pre-commit hook 已安裝
    • ✅ git log –all — ‘*.env’ 確認歷史中無敏感文件

    Jenkins Pipeline

    • ✅ 第一個 Stage 為 Secrets Scan(gitleaks)
    • ✅ Sign Images Stage 已配置(cosign)
    • ✅ Push Images 使用 Jenkins Credentials(非明文)
    • ✅ GitHub Webhook Secret 已配置(Jenkins + GitHub 雙端)

    K8s 訪問控制

    • ✅ k8s/rbac/ 三個 RBAC 文件已 apply
    • ✅ Jenkins SA 驗證:kubectl auth can-i get secrets … → no
    • ✅ Kyverno 已安裝,鏡像簽名驗證策略已 apply
    • ✅ etcd encryption at rest 已啟用(運維確認)

    審計和監控

    • ✅ K8s Audit Log 已配置(audit-policy.yaml)
    • ✅ Audit Log 保留策略 ≥ 90 天
    • ✅ 告警規則已配置(部署失敗、Secrets 掃描失敗)

    總結:這套工作流解決了什麼問題?

    AI 輔助開發的核心挑戰不是技術,而是信任邊界:誰能做什麼?誰為每個決定負責?這套工作流的答案很清楚:

    • AI 的邊界:寫 code、提 PR、建 Docker image — 技術執行層
    • 用戶的邊界:review 代碼、創建 tag、手動部署 Production — 決策層
    • 運維的邊界:管理 Secrets、維護集群、配置 credentials — 基礎設施層
    • 自動化的邊界:Jenkins 在 tag 觸發後執行既定腳本 — 不越界,不決策

    這種分層設計讓 AI 協作既高效又安全,每一個部署都有完整的審計軌跡,每一個敏感操作都需要人類授權。

  • 多通路電商 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 設計