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 客戶端

留言

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *