前言: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:對應平台 API 的原始格式
- 內部 DTO:系統統一的資料格式
- 轉換器:負責格式轉換
層次說明
| 層次 | 命名規則 | 範例 | 用途 |
|---|---|---|---|
| 外部 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;
}
轉換器:格式轉換
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;
};
}
}
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 |
使用範例
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();
}
// … 其他平台
}
}
總結
| 設計 | 效果 |
|---|---|
| 外部 DTO 對應 API | API 變更只影響一個類別 |
| 內部 DTO 統一格式 | 業務邏輯不受平台影響 |
| 獨立轉換器 | 轉換邏輯集中管理 |
| 狀態對照表 | 統一的訂單狀態 |
為什麼不用其他方案?
| 方案 | 優點 | 缺點 | 結論 |
|---|---|---|---|
| 直接用 Map | 不用定義類別 | 無型別安全、IDE 無法幫忙 | 除錯困難 |
| 一個 DTO 打天下 | 類別數量少 | 欄位爆炸、不知道哪些是哪個平台 | 維護噩夢 |
| MapStruct 自動轉換 | 減少手寫程式碼 | 複雜轉換還是要手寫 | 可搭配使用 |
| 三層 DTO + 轉換器 | 清晰、可測試 | 類別數量多 | 大型系統首選 |
實戰踩坑
蝦皮某次升級把 item_list 改成 items,只有外部 DTO 需要改,業務邏輯完全不受影響。如果沒有分層,全系統都要搜尋取代。
PChome 新增了一個「部分出貨」狀態,我們的對照表沒有,結果 mapping 成 UNKNOWN,訂單卡住不處理。教訓:每個平台的狀態值要定期 review。
最初 Converter 只做欄位 mapping,後來塞進去驗證、預設值、業務邏輯…變成 God Class。後來拆成 Converter(純 mapping)+ Validator + Enricher,各司其職。
系列導航
| ◀ 上一篇 健康檢查 |
📚 返回目錄 | 下一篇 ▶ HTTP 客戶端 |
發佈留言