分類: 最佳實踐

  • 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 事件驅動
    📚 返回目錄 下一篇 ▶
    健康檢查
  • 多通路電商系統架構:用工廠模式整合 17 個平台

    商業價值:這篇介紹的工廠模式讓「新增平台從 2-3 個月縮短到 2-3 週」,直接影響 導讀篇提到的 80% 擴展成本降低

    前言:當你要同時對接 17 個電商平台

    在多通路電商系統中,我們需要整合多個平台:

    平台類型 範例 特性
    綜合電商 蝦皮、Momo、Yahoo、PChome 訂單量大、API 複雜
    開店平台 Shopify、Shopline、91APP 彈性高、客製化多
    國際平台 樂天、Coupang、Amazon 多語系、跨境物流
    問題:每個平台的 API 格式、認證方式、資料結構都不同。如果用 if-else 判斷,程式碼會變成災難。

    解決方案:工廠模式 + 策略模式

    核心思想:定義統一介面,每個平台各自實作。新增平台時,只需要新增一個實作類別。

    Step 1:定義統一介面

    所有平台都必須實作這個介面:

    /**
    * 電商平台動作介面
    * 所有平台整合都必須實作這個介面
    */

    public interface ChannelAction {

    // 取得平台設定(API URL、版本等)
    ChannelSetting getSetting();

    // 取得平台授權 Token
    TokenResult getAccessToken(Merchant merchant);

    // 驗證必要資料是否齊全
    boolean validateRequired(ActionRequest request);

    // 執行實際動作(同步訂單、更新庫存等)
    ActionResult execute(ActionRequest request);
    }

    Step 2:每個平台各自實作

    蝦皮實作範例:

    @Component
    public class ShopeeAction implements ChannelAction {

    @Override
    public ChannelSetting getSetting() {
    return ChannelSetting.builder()
    .apiUrl(“https://partner.shopeemobile.com”)
    .version(“v2”)
    .authType(AuthType.OAUTH2)
    .build();
    }

    @Override
    public TokenResult getAccessToken(Merchant merchant) {
    // 蝦皮使用 OAuth2 + 簽章驗證
    String signature = generateSignature(merchant);
    return callShopeeAuthAPI(merchant, signature);
    }

    @Override
    public ActionResult execute(ActionRequest request) {
    // 呼叫蝦皮 API 執行動作
    return callShopeeAPI(request);
    }
    }

    Momo 實作範例:

    @Component
    public class MomoAction implements ChannelAction {

    @Override
    public ChannelSetting getSetting() {
    return ChannelSetting.builder()
    .apiUrl(“https://api.momo.com.tw”)
    .version(“v1”)
    .authType(AuthType.API_KEY)
    .build();
    }

    @Override
    public TokenResult getAccessToken(Merchant merchant) {
    // Momo 使用 API Key 驗證
    return TokenResult.of(merchant.getMomoApiKey());
    }

    @Override
    public ActionResult execute(ActionRequest request) {
    // 呼叫 Momo API 執行動作
    return callMomoAPI(request);
    }
    }

    Step 3:工廠類別統一管理

    @Component
    public class ChannelFactory {

    private final Map<ChannelType, ChannelAction> actionMap;

    // Spring 自動注入所有 ChannelAction 實作
    public ChannelFactory(List<ChannelAction> actions) {
    this.actionMap = actions.stream()
    .collect(Collectors.toMap(
    action -> action.getSetting().getChannelType(),
    action -> action
    ));
    }

    /**
    * 根據通路類型取得對應的實作
    */

    public ChannelAction getAction(ChannelType channelType) {
    ChannelAction action = actionMap.get(channelType);
    if (action == null) {
    throw new UnsupportedChannelException(
    “不支援的通路: “ + channelType
    );
    }
    return action;
    }
    }


    使用方式

    業務邏輯層只需要這樣呼叫:

    @Service
    public class OrderSyncService {

    @Autowired
    private ChannelFactory channelFactory;

    public SyncResult syncOrders(ChannelType channel, Merchant merchant) {
    // 1. 取得對應的通路實作
    ChannelAction action = channelFactory.getAction(channel);

    // 2. 取得授權 Token
    TokenResult token = action.getAccessToken(merchant);

    // 3. 執行同步
    ActionRequest request = ActionRequest.builder()
    .merchant(merchant)
    .token(token)
    .actionType(ActionType.SYNC_ORDERS)
    .build();

    return action.execute(request);
    }
    }

    優點:不管是蝦皮、Momo、Yahoo 還是其他平台,呼叫方式完全一樣。新增平台時,業務邏輯層完全不用改。

    新增平台有多簡單?

    假設要新增 Coupang 韓國平台:

    @Component
    public class CoupangAction implements ChannelAction {

    @Override
    public ChannelSetting getSetting() {
    return ChannelSetting.builder()
    .apiUrl(“https://api-gateway.coupang.com”)
    .version(“v2”)
    .authType(AuthType.HMAC)
    .build();
    }

    // … 實作其他方法
    }

    步驟 工作內容 影響範圍
    1 建立 CoupangAction 類別 只有新檔案
    2 實作 ChannelAction 介面 只有新檔案
    3 加上 @Component 註解 只有新檔案
    4 完成!Spring 自動註冊 零修改現有程式

    設計模式總結

    模式 用途 在這裡的應用
    策略模式 定義演算法家族,讓它們可互換 每個平台是一個策略
    工廠模式 封裝物件建立邏輯 根據通路類型取得實作
    依賴注入 解耦合 Spring 自動管理
    效益:

    • 新增通路:從 2-3 個月縮短到 2-3 週
    • 維護成本:改一個平台不影響其他平台
    • 測試:每個平台可以獨立單元測試

    為什麼不用其他方案?

    方案 優點 缺點 結論
    if-else 判斷 簡單直接 每次加平台要改核心程式碼 小規模可用,超過 3 個平台就很痛苦
    Switch Case 比 if-else 清楚 還是要改核心程式碼 同上
    反射 + 設定檔 完全不改程式碼 除錯困難、IDE 無法追蹤 過度設計,維護成本高
    工廠 + 策略 新增只加檔案、Spring 自動註冊 需要理解設計模式 中大型系統的最佳平衡

    實戰踩坑

    坑 1:平台 API 變更沒通知

    蝦皮某次 API 升級,回傳欄位名稱從 order_sn 改成 ordersn。因為每個平台有獨立的 Action 類別,我們只需要改 ShopeeAction,其他 16 個平台完全不受影響。如果用 if-else,改錯一行就全部爆炸。

    坑 2:忘記加 @Component

    新人寫好 CoupangAction 卻沒加 @Component,Spring 沒註冊到 Factory。呼叫時直接噴 UnsupportedChannelException。後來在程式碼審查加入檢查項:「確認新 Action 有 @Component」。

    坑 3:介面設計太死

    最初 ChannelAction 只有 execute() 一個方法。後來發現有些平台需要 OAuth 刷新 Token、有些需要 Webhook 處理。介面改了三次才穩定。教訓:先做 3-5 個平台再抽象,別一開始就過度設計


    系列導航

    ◀ 上一篇
    導讀篇
    📚 返回目錄 下一篇 ▶
    Kafka 事件驅動