標籤: Java

  • 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 客戶端
  • 企業級多租戶認證: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 事件驅動
  • schemaSpy

    結論

    這套工具可以掃描資料庫來產資料庫文件,But前提是你的DB 表欄位的關聯要做好,其次他產出後的文件並沒有BI需要後續整理,但是以第一步來說,沒問題

    (閱讀全文…)

  • SOLR 實戰問題

    2024618大戰

    具體問題

    大總管job機 64台 一秒鐘處理一條queue
    大總管job機 128台 兩秒鐘處理一條queue
    機器開兩倍 速度完全沒起來

    (閱讀全文…)