標籤: Java

  • Spring Boot OMS Code Review 實戰:20 個 Bug 與事件驅動架構的一課

    重點摘要

    • 三輪 Code Review 共找出 20+ 個問題,從 NPE 連鎖到種子資料欄位名稱全錯
    • Kafka Seeder 寫了三個版本,每次重寫都是對「事件驅動架構正確入口」理解的加深
    • 能走事件流就走事件流:API → Kafka → Consumer → DB,每一層都有可追蹤、可重試的意義
    • 21 個 Java 容器沒有 JVM 記憶體限制,用 JAVA_TOOL_OPTIONS 一行解決,不需改 Dockerfile

    這是 多通路電商 OMS 系統開發過程中的一天工作紀錄。系統整合了 Momo、Shopee、Yahoo 等電商平台,透過 Kafka 事件流處理訂單同步、退貨與統計。今天的目標:完成 feature/stats-pipeline 分支上的所有待辦修復,讓系統能順利 docker compose up,並驗證端對端資料流。

    三輪 Code Review:每一輪都有新發現

    第一輪:已知清單上的 7 個問題

    進入狀態之前就有一份清單,分為 Critical、Warning、Info 三個等級:

    等級 問題 修復方式
    CriticalOrderUpsertConsumer .get() NPE 連鎖.path() + 加 orderDataJson null 守衛
    Criticaldaily_statistics.id 缺 NOT NULL加約束 + DEFAULT partition
    CriticalReturnUpsertConsumer 未寫 stats dirty marker新增 Redis ZSET 寫入
    WarningDailyStatisticsService early return 留舊資料改成刪除過時的 stats 列
    Warningenum 預設值小寫 'pending'改大寫 'PENDING',與 JPA EnumType.STRING 對齊
    WarningRetryJobConsumer MissingNode cast.isObject() 判斷再 cast

    其中 enum 大小寫這個問題值得特別說明。Java 的 @Enumerated(EnumType.STRING) 在讀取時呼叫 Enum.valueOf(),這個方法是 case-sensitive 的。資料庫預設值寫 'pending',但 enum 常數叫 PENDING,啟動時不會出錯,但一讀到有 DEFAULT 值的列就會拋 IllegalArgumentException

    第二輪:種子資料是另一個地雷區

    Schema 修完了,以為大功告成,結果種子資料(02-seed-data.sql)是第二個地雷區:

    1. BCrypt hash 是假的$2a$10$dummyhashfordevonly... 根本不是有效的 BCrypt hash,Spring Security 的 passwordEncoder.matches() 永遠回傳 false,登入 100% 失敗。
    2. 訂單狀態小寫'completed''shipped' — 和上面一樣的 case-sensitive 問題,這次在資料列而不是 DEFAULT 值。
    3. daily_statistics 欄位名稱全錯:用了 order_counttotal_amount 這些不存在的欄位名,docker compose up 的 DB 初始化階段會直接 fail。

    這些問題的共同特徵是:compile time 抓不到,schema validate 也抓不到。Hibernate 的 ddl-auto: validate 只單向檢查「entity 中有 mapping 的欄位是否存在於 DB」,不會反向驗證 SQL 腳本的正確性。唯一的防護是跑起來測試。

    第三輪:21 個容器,一個 JVM 記憶體問題

    系統在開發機上跑 21 個 Java 容器(Spring Boot services),沒有任何 JVM heap 限制。JVM ergonomic sizing 預設使用系統 RAM 的 25%,7.4GB 可用 RAM 很快就會不夠。

    解法是在 docker-compose.yml 每個服務加 JAVA_TOOL_OPTIONS

    environment:
      JAVA_TOOL_OPTIONS: "-Xmx256m -XX:+ExitOnOutOfMemoryError"

    JAVA_TOOL_OPTIONS 是 JDK 標準環境變數,JVM 啟動時自動讀取,不需要修改 Dockerfile 的 ENTRYPOINT-XX:+ExitOnOutOfMemoryError 讓容器在 OOM 時立刻崩潰(而不是卡死),對 Docker 的 restart: unless-stopped 友好,等於有了自動恢復機制。

    Seeder 的三次重寫:對事件驅動架構的理解之旅

    今天最有收穫的插曲。目標是「準備一個 Docker 服務,打假訂單資料,確認整體資料流順暢」。這個任務看起來很簡單,結果寫了三個版本。

    第一版:直接打 Kafka(被打槍)

    第一直覺:用 kafka-python 直接連 kafka:9092,組好 ORDER_UPSERT 訊息送到 order.process topic。快速、直接。

    問題:系統對外只有 API,直接操作 Kafka 是繞過了系統設計的邊界。內部基礎設施不應該是外部系統的接入點。

    第二版:打 POST /api/orders(沒走事件流)

    改用 REST API。先 login 拿 JWT,再 POST /api/orders

    問題:OrderController.createOrder() 是直接寫資料庫,跳過了整個 Kafka pipeline。Stats dirty marker 不會被寫入,DailyStatisticsService 不會被觸發,daily_statistics 表不會更新。雖然訂單進了 DB,但「整體資料流」沒有跑通。

    第三版:新增正確的 API 端點(走完整事件流)

    UserOrderController 新增 POST /api/user/orders,接收訂單資料後發布 ORDER_UPSERT 到 Kafka,回傳 202 Accepted:

    POST /api/user/orders  (帶 JWT)
      → 查 Channel → Platform(取得 platformId)
      → 組 ORDER_UPSERT 訊息(header + body + hash)
      → kafkaTemplate.send("order.process", ...)
      → 回傳 202 Accepted
    
    接著:
      Kafka order.process
        → OrderUpsertConsumer(Redis 去重 → INSERT/UPDATE)
            → stats dirty marker 寫入 Redis ZSET
                → StatsRecalcHandler(定時掃)
                    → DailyStatisticsService.recalculate()
                        → daily_statistics 更新

    端到端,一條不少。

    為什麼「能走事件流就走事件流」不只是口號

    三次重寫讓這個原則從抽象變得具體。走事件流的好處不只是「解耦」這個詞能涵蓋的:

    層面 直接寫 DB 走 Kafka 事件流
    可追蹤性只有 DB recordKafka UI 可看完整訊息歷史,帶 traceId
    錯誤處理拋 exception,呼叫方看到 500失敗走 task.failed → retry → task.dlt
    去重需要自己實作Consumer 有 Redis + DB 兩層去重
    統計觸發需要額外呼叫Consumer 自動寫 dirty marker,批次計算
    一致性邏輯分散在多處無論來源(channel job / API),走同一套邏輯

    最後一點是最重要的:一致性。不管訂單是從 Shopee channel job 來的,還是透過 API 手動新增的,都走同一個 OrderUpsertConsumer,同一套去重邏輯,同一套 stats pipeline。系統裡沒有「繞過」的快捷路徑。

    今日修改摘要

    檔案 類型 說明
    01-schema.sqlBug FixNOT NULL、DEFAULT partition、enum 大小寫
    02-seed-data.sqlBug FixBCrypt hash、訂單狀態大小寫、daily_statistics 欄位名稱
    OrderUpsertConsumerBug Fix.get().path(),移除 unused import
    ReturnUpsertConsumerBug Fix加 stats dirty marker、移除 unused import
    DailyStatisticsServiceBug Fixearly return 時刪除過時 stats 列
    OrderServiceBug FixNOT NULL 欄位的 null 守衛
    docker-compose.ymlInfra所有 21 個 Java 容器加 JAVA_TOOL_OPTIONS
    UserOrderControllerFeature新增 POST /api/user/orders → Kafka pipeline
    docker/test-data-generator/FeaturePython seeder,透過 API 打假訂單

    結語:追蹤路徑比結果更重要

    今天花最多時間的不是寫 code,而是「把對的事情弄清楚」。Seeder 寫了三個版本,不是因為技術難,而是因為對系統的理解在逐漸深化。

    一個好的事件驅動系統,它的「正確入口」只有一個。找到那個入口,比快速把功能做出來更重要。這條原則同樣適用於大系統的任何角落:追蹤路徑比結果更重要,因為你下次出問題的時候,你需要知道訊息從哪裡來、往哪裡去。

    能走事件流就走事件流。能用快取盡量快取。這不是教條,是讓大系統在出問題時還能被追蹤、被診斷、被修復的保險。

  • iDempiere Plugin 開發完整指南:踩遍台灣統一發票的 10 個坑

    重點摘要

    • 我們用兩天為 iDempiere 12.0 從零開發了台灣統一發票 OSGi Plugin,踩了至少 7 個主要的坑
    • 最燒時間的 bug:SeqNoGrid 缺失導致 Grid View 在按「新增」時拋出 IndexOutOfBoundsException——官方文件完全沒提
    • EventHandler 模式是 iDempiere 12 的正確驗證方式,ModelValidator 不透過 OSGi DS 登錄根本不執行
    • 2Pack ZIP 結構、事件主題比較、雙 JVM 部署問題——每一個都能讓你白白浪費半天

    這篇文章記錄了我們為 iDempiere 12.0 從零開發台灣統一發票(統一發票)與營業稅申報 Plugin 的完整過程。如果你正在開發 iDempiere Plugin,這篇文章能幫你少掉至少一天的除錯時間。

    為什麼要做這個 Plugin?

    台灣的統一發票制度獨特而嚴格:每兩個月一期的雙月申報週期、財政部核配的字軌號碼(如 AA01234567)、三聯式(B2B)和二聯式(B2C)的不同計稅方式、以及嚴格的 FLOOR 取捨規定。

    這些都是 iDempiere 標準功能完全沒有覆蓋的。既有的 C_Invoice 系統不知道什麼是「字軌」,不知道進項折讓要在哪一期申報,更不知道 401 申報表長什麼樣子。所以我們做了這個 Plugin。

    系統架構概覽:四張表對應四個階段

    iDempiere Plugin 開發的核心是理清業務模型。台灣統一發票有清楚的生命週期,我們用四張資料表對應:

    財政部核配字軌
          │
          ▼
    TW_InvoicePrefix      ← 字軌管理(AA、AB 等,號碼範圍、有效期)
          │ 開立發票時
          ▼
    TW_Invoice_Prefix_Map ← 每張發票的字軌號碼對應(含買方統一編號)
          │ 如有退貨/折讓
          ▼
    TW_InvoiceAdjustment  ← 銷項/進項折讓(方向、期別、超期申報)
          │ 期末
          ▼
    TW_TaxStatement       ← 401 申報表(銷項稅、進項稅、留抵、應納稅額)

    技術層面:OSGi bundle(Equinox),2Pack 管理 dictionary,AbstractEventHandler 做 PO 事件驗證,服務層純 Java 方便單元測試。最終成品:87 個測試全部通過,4 張 TW_* 資料表,4 個 iDempiere 視窗。

    把台灣稅法翻成 Java 程式碼

    稅額計算:FLOOR,不是 ROUND

    這是財政部的明確規定,一律捨去,不四捨五入。聽起來簡單,但在邊界值差一塊錢,申報出去就是對不上帳:

    // 二聯式(B2C):含稅金額已知,反推銷售額和稅額
    BigDecimal saleAmount = grossAmount
        .divide(new BigDecimal("1.05"), 0, RoundingMode.FLOOR);
    BigDecimal taxAmount = saleAmount
        .multiply(new BigDecimal("0.05"))
        .setScale(0, RoundingMode.FLOOR);
    
    // 注意:taxAmount ≠ grossAmount - saleAmount
    // 財政部規定:用前者(先算銷售額,再乘 5%)

    字軌狀態機:單向前進

    字軌狀態只能往前走,不能回頭:I(未啟用)→ A(使用中)→ C(已用完)。這是台灣稅法要求,已啟用的字軌必須被追蹤,不能撤回。

    // EventHandler 在 PO_BEFORE_CHANGE 攔截
    if ("A".equals(oldStatus) && "I".equals(newStatus))
        throw new AdempiereException("使用中字軌不可降回未啟用(台灣稅法)");
    if ("C".equals(oldStatus))
        throw new AdempiereException("已用完字軌不可再變更狀態");

    雙月申報期別計算

    // 從發票月份算期別
    int period = (month - 1) / 2 + 1;  // 1=1-2月, 2=3-4月 ... 6=11-12月

    七個真實踩坑紀錄

    坑 1:事件主題比較,一個字毀掉一切

    這個 bug 讓狀態驗證完全靜默失效了很久。症狀是:程式碼看起來完全正確,但驗證從來不觸發。

    // 錯誤 — 永遠不會觸發
    if (topic.endsWith("po_before_change")) { ... }
    
    // 正確
    if (IEventTopics.PO_BEFORE_CHANGE.equals(topic)) { ... }

    iDempiere 的 topic 格式是 org/adempiere/po/PO_BEFORE_CHANGE(全大寫),endsWith 配對小寫後綴當然不匹配。靜默失效最可怕——不報錯,只是所有驗證都沒執行。規則:事件主題永遠用 IEventTopics 常數。

    坑 2:ModelValidator 是個陷阱

    Validator 類別一開始實作了完整的 ModelValidator 介面(initialize、modelChange、docValidate、login…),程式碼寫得很整齊,但驗證從來不執行。

    原因:ModelValidator 需要透過 ModelValidationEngine.addModelValidator() 主動登錄。單純 implements ModelValidator + @Component 什麼都不會發生。

    正確的 iDempiere 12 Plugin 驗證模式:

    // *Validator.java    → 純靜態方法,不實作任何介面
    // *EventHandler.java → extends AbstractEventHandler,OSGI-INF/*.xml 登錄為 DS 服務
    
    // EventHandler 捕捉 OSGi 事件,呼叫 Validator 的靜態方法做驗證
    @Component(immediate = true, service = IEventHandler.class)
    public class InvoicePrefixEventHandler extends AbstractEventHandler {
        @Override
        protected void doHandleEvent(Event event) {
            String topic = (String) event.getProperty(IEventTopics.EVENT_TOPIC);
            if (IEventTopics.PO_BEFORE_CHANGE.equals(topic)) {
                String err = InvoicePrefixValidator.validateStatusTransition(...);
                if (err != null) throw new AdempiereException(err);
            }
        }
    }

    坑 3:2Pack ZIP 打包方式,犯了兩次

    iDempiere 的 PackIn 解壓 ZIP 到 /tmp/,預期路徑是 /tmp/tw_invoice_system/dict/PackOut.xml

    第一次:ZIP 裡多了 2pack/ 前綴層。第二次:用了 zip -j(junk paths),把所有目錄剝掉,路徑變成 /tmp/PackOut.xml/dict/PackOut.xml——把文件名當目錄了。

    # 正確打包方式
    mkdir -p /tmp/b/tw_invoice_system/dict
    cp PackOut.xml /tmp/b/tw_invoice_system/dict/
    cd /tmp/b && zip -r 2Pack_1.0.10.zip tw_invoice_system/
    
    # 永遠要驗證結構
    unzip -l 2Pack_1.0.10.zip

    坑 4:SeqNoGrid 缺失 → Grid View 崩潰(最燒時間)

    這是整個過程中最「無辜」的 bug——不是邏輯錯誤,是 XML 少了兩個欄位。所有 73 個 AD_Field 元素都缺少 <SeqNoGrid> 和正確的 <IsDisplayedGrid>

    iDempiere 12 的 Grid View 渲染器在初始化行編輯器時需要 SeqNoGrid 做排序依據,全部 NULL 導致:

    java.lang.IndexOutOfBoundsException: Index: 2
        at GridTabRowRenderer.editCurrentRow

    任何視窗,只要在 Grid 模式下按「新增」,必爆。這個 bug 在官方文件裡完全沒有提到。修復:補上這兩個欄位。

    <SeqNo>10</SeqNo>
    <SeqNoGrid>10</SeqNoGrid>        <!-- = SeqNo,顯示欄位 -->
    <IsDisplayedGrid>Y</IsDisplayedGrid>
    
    <!-- 隱藏欄位(SeqNo=0)-->
    <SeqNoGrid>0</SeqNoGrid>
    <IsDisplayedGrid>N</IsDisplayedGrid>

    坑 5:兩個 JVM 搶 Port,Deploy 進錯的那個

    症狀很奇怪:OSGi telnet console 顯示 bundle ACTIVE ✅,但 Web Console 找不到這個 bundle ❌。

    最後發現機器上跑著兩個 iDempiere JVM:

    JVM 擁有 Port 說明
    JVM-A(舊的)8080/8443Web 瀏覽器連這個
    JVM-B(新的)12612OSGi telnet 連這個

    deploy.sh 透過 telnet 把 bundle 裝進了 JVM-B,但使用者看的 Web Console 是 JVM-A。原因是 idempiere-server.sh 有 restart loop,systemctl restart 啟動了新 JVM,但舊 JVM 沒死。

    # 診斷方法
    ps aux | grep java | grep -v grep | wc -l  # > 1 就是這個問題
    
    # 修復:kill 兩個 JVM,等 30 秒讓 port 釋放,再 restart
    sudo kill <pid1> <pid2>
    sleep 30
    sudo systemctl restart idempiere

    坑 6:AD_Field UUID 衝突

    2Pack 重裝時,iDempiere 已為新建的 Tab 自動插入標準欄位(AD_Client_ID、AD_Org_ID、IsActive)。當 PackIn 再次嘗試用不同 UUID 插入同一個 (tab_id, column_id) 組合時,違反了 UNIQUE(ad_tab_id, ad_column_id) 約束,拋出 POSaveFailedException

    解法:對這類「系統可能已存在」的標準欄位,PackOut.xml 使用固定的 placeholder UUID。PackIn 遇到重複就 UPDATE 而不是 INSERT。升版 UUID 策略:已存在的 field → 保留原 UUID;新增的 field → 才用新的 uuid4。不要為了「整齊」換掉既有 UUID。

    坑 7:清除 DB 做全新安裝——FK 刪除順序

    當要做「完整卸載 → 清 DB → 重裝」的驗證時,刪資料的順序錯了好幾次。

    正確順序:AD_Field → AD_Tab → AD_Window,每一步都有 FK 指向下一個。特別注意:AD_PreferenceAD_Menu 也會 FK 到 AD_Window,忘記刪這兩個就卡住。另外:ad_package_imp 記錄了 2Pack 安裝歷史,不清掉的話 Incremental2PackActivator 判定「已安裝過同版本」就跳過不執行。

    如果重來一次,我們會怎麼做

    1. 先建立 PackOut.xml 驗證腳本

    每次 2Pack 安裝後自動跑這些 SQL,出問題立刻知道,不用等到 UI 爆炸:

    -- SeqNoGrid 是否設定
    SELECT tablename, count(*) FILTER (WHERE seqnogrid > 0) ok
    FROM ad_table JOIN ad_tab ... JOIN ad_field ...
    WHERE tablename LIKE 'TW_%' GROUP BY tablename;
    
    -- _UU 欄位是否 updateable
    SELECT columnname, isupdateable FROM ad_column
    WHERE tablename LIKE 'TW_%' AND columnname LIKE '%_UU';

    2. 把 ZIP 結構驗證寫進 Maven build

    <plugin>
      <groupId>org.codehaus.mojo</groupId>
      <artifactId>exec-maven-plugin</artifactId>
      <executions>
        <execution>
          <phase>verify</phase>
          <goals><goal>exec</goal></goals>
          <configuration>
            <executable>bash</executable>
            <arguments>
              <argument>-c</argument>
              <argument>unzip -l resources/META-INF/2Pack_*.zip | grep -q "dict/PackOut.xml"</argument>
            </arguments>
          </configuration>
        </execution>
      </executions>
    </plugin>

    3. 每次 commit 前跑 deploy.sh –check

    加一個 dry-run 模式,只驗證 JAR 可部署、OSGi console 可連線、bundle 存在,不實際更新。讓這個檢查成為 commit hook。

    給下一個要做 iDempiere Plugin 的人

    iDempiere 的文件很少,很多行為只有讀原始碼才能理解。以下是花最多時間搞懂的六件事:

    # 規則 說明
    1ModelValidator 不是 OSGi 服務@Component 不會讓它跑起來,要用 EventHandler
    2SeqNoGrid 是 Grid View 必備欄位缺了不報錯,只在按「新增」時崩潰
    32Pack ZIP 結構有嚴格要求打包後一定要 unzip -l 驗證,絕不用 zip -j
    4_UU 欄位必須 IsUpdateable=Y否則 UUID 永遠 NULL
    5IEventTopics 常數不要用字串字面量比較 topic,靜默失效
    6Incremental2PackActivator 記住安裝歷史同版本不重裝,升版要改 ZIP 檔名

    後記:實作兩個流程(又踩了三個坑)

    文章發完當天,隨即著手實作原本標注為「計劃中功能」的兩支 SvrProcess

    • GenerateTaxStatementProcess — 聚合 TW_Invoice_Prefix_Map 的銷售資料、折讓,產生 TW_TaxStatement 申報記錄
    • ExportTaxReportProcess — 讀取 TW_TaxStatement,輸出財政部格式 CSV

    同時也需要把這兩個流程透過 2Pack 登錄到 iDempiere 的 AD_Process / AD_Menu,讓使用者可以從選單觸發。這一輪又多踩了幾個坑。

    坑 8:AD_Process_Para 的 AD_Process_ID 必須明確寫入

    2Pack 的元素處理器(ElementHandler)有一個隱藏的不一致性

    AD_Tab 時,把它放在 <AD_Window> 元素內,Tab 的 AD_Window_ID 會自動繼承父元素——不需要重複宣告。大多數巢狀元素都這樣工作。

    AD_Process_Para 不同。ProcessParaElementHandler 呼叫 filler.autoFill() 處理子元素,但不從父 <AD_Process> 讀取 context 填入 AD_Process_ID

    結果:2Pack 安裝過程看起來正常執行,沒有任何錯誤訊息,但 Process Para 根本沒有存入。直到手動查 DB 才發現空的。真正的錯誤需要翻 PostgreSQL log:

    tail -f /var/log/postgresql/postgresql-16-main.log
    # ERROR: null value in column "ad_process_id" violates not-null constraint

    修正:每個 <AD_Process_Para> 元素內都必須顯式宣告 AD_Process_ID

    <AD_Process_Para type="table">
      <AD_Process_ID reference="uuid" reference-key="AD_Process">{process-uuid}</AD_Process_ID>
      <Name>Statement Year</Name>
      ...
    </AD_Process_Para>

    坑 9:FieldLength NOT NULL(但 iDempiere log 不說)

    修好 AD_Process_ID 之後,再次安裝,再次失敗,再次查 PostgreSQL log:

    ERROR: null value in column "fieldlength" violates not-null constraint

    FieldLengthad_process_para 資料表中是 NOT NULL 欄位,但如果 PackOut.xml 沒有提供,iDempiere 的 log 只會說 Failed to save ProcessPara——沒有欄位名稱,沒有 SQL,什麼都沒有。

    不同 Reference 型別的預設長度:

    AD_Reference_ID 型別 FieldLength
    11Integer10
    17List1
    10String實際最大長度

    教訓:2Pack 的錯誤訊息有時候刻意模糊。遇到 Failed to save 之類的通用錯誤,直接去查 PostgreSQL log,那裡才有真相。

    坑 10:iDempiere 物理表在 adempiere schema,不在 public

    寫完 clean_reinstall.sh(一個用來清除所有 TW_* 字典並重新部署的腳本)後,發現 DROP TABLE 完全沒作用:

    DROP TABLE IF EXISTS TW_InvoicePrefix;  -- 執行成功,但表還在

    原來 iDempiere 12.0 把物理表建在 adempiere schema,而不是 public。用 information_schema.tables WHERE table_schema='public' 查詢,一張 TW_* 表都找不到。

    正確用法:

    -- 確認表存在
    SELECT tablename FROM pg_tables WHERE schemaname='adempiere' AND tablename ILIKE 'tw_%';
    
    -- 刪除表
    DROP TABLE IF EXISTS adempiere.TW_InvoicePrefix;
    DROP TABLE IF EXISTS adempiere.TW_Invoice_Prefix_Map;

    這也影響 psql 互動查詢——連線後預設 search_path 如果不含 adempiere,直接 SELECT * FROM TW_InvoicePrefix 會找不到表。

    SvrProcess 實作要點

    SvrProcess 的標準結構很直覺——prepare() 讀參數、doIt() 做事。幾個需要注意的地方:

    • 不要呼叫 ps.setAD_Client_ID():那是 PO 類別的 protected 方法,不是 PreparedStatement 的方法。在 SvrProcess 裡用 getAD_Client_ID() 取值,直接 ps.setInt(1, getAD_Client_ID())
    • DB.prepareStatement() 而不是 JDBC 直接連線:iDempiere 的 DB class 管理連線池和事務,不要繞過它。
    • 記得 DB.close(rs, ps):在 finally block 釋放資源。

    clean_reinstall.sh — 開發期間的救命工具

    開發期間反覆修改 PackOut.xml,每次都要手動清資料庫重裝,非常繁瑣。寫了一個腳本自動化整個流程:

    1. 依 FK 順序刪除所有 TW 字典(Window_Access → Process_Access → Field → Tab → Menu → Window → Process_Para → Process)
    2. Drop 物理表(adempiere.TW_*
    3. 清除後設資料(Column → Table → Sequence → Element → Reference → EntityType)
    4. 清 AD_Package_Imp_Detail / AD_Package_Imp(注意:Detail 要先刪,因為有 FK)
    5. 呼叫 deploy.sh 重新部署

    FK 刪除順序是最麻煩的部分——錯一個順序就會碰到 FK 違規,整個腳本失敗。從失敗中整理出來的正確順序如上。

    最終成果(v1.0.11)

    Bundle:    tw.idempiere.invoice.tax v1.0.0 (2Pack v1.0.11)
    Tests:     89 個,全數通過
    Tables:    4 張 TW_* 資料表
    Windows:   4 個 iDempiere 視窗
    Processes: 2 個(GenerateTaxStatement + ExportTaxReport)
    Fields:    73 個 AD_Field(含正確 SeqNoGrid)
    Menu:      7 個項目(1 父選單 + 4 視窗 + 2 流程)
    Status:    ACTIVE,Grid View 正常,流程可從選單觸發
  • 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
    機器開兩倍 速度完全沒起來

    (閱讀全文…)