整合付款訂單資料:可雙寫、可核對、可回滾的遷移方法

將分散的課程、訂閱與顧問付款整合成統一 order 模型,同時保留 webhook 去重、金額核對與逐步回滾。

付款資料遷移最危險的時刻,不是 ALTER TABLE 執行失敗,而是新舊系統都回應成功,卻對「同一筆訂單」有不同定義。課程購買、月費訂閱、顧問預約可能各自保存 customer、金額與 provider reference;如果直接把三張表合併,很容易重複授權、遺漏退款,或把尚未完成的 Checkout 當作收入。 截至 2026-07-29,Stripe 官方仍要求 webhook endpoint 驗證簽章,並提醒同一事件可能重複送達;PostgreSQL 的交易與 ON CONFLICT 則提供原子寫入和衝突處理的基礎。安全遷移要把這些行為變成 schema invariant,而不是依賴「webhook 通常只來一次」。 實作步驟 第一步先定義名詞,不要先搬資料。建議拆成 orders 、 order_items 、 payment_attempts 與 payment_events 。order 表達使用者要買什麼;payment attempt 表達某次向 provider 付款的嘗試;event 是 provider 送來的不可變訊息。退款與爭議應保存為狀態轉移或獨立 ledger entry,不能把原始付款金額直接覆寫成零。 金額應保存最小貨幣單位的整數,並與 ISO currency 一起存。不要用浮點數。訂單狀態也不能只靠自由文字;至少分清 pending 、 paid 、 cancelled 、 refunded 等業務狀態,並規定誰有權轉移。以下是簡化 schema,實務上還需依產品補足 foreign key 與權限: create table orders ( id uuid primary key default gen_random_uuid(), customer_id uuid not null, status text not null check (status in ('pending','paid','cancelled','refunded')), currency text not null, amount_total bigint not null check (amount_total >= 0), source_system text not null, source_order_id text not null, created_at timestamptz not null default now(), unique (source_system, source_order_id) ); create table payment_events ( provider text not null, provider_event_id text not null, event_type text not null, payload jsonb not null, received_at timestamptz not null default now(), processed_at timestamptz, primary key (provider, provider_event_id) ); 第二步建立來源對照表與資料剖析報告。對每張舊表記錄主鍵、使用者鍵、金額單位、currency、時區、nullable 欄位、provider ID 與狀態語意。列出孤兒記錄、重複 provider reference、不合法金額、找不到 user 的資料。這些項目不可用猜測修補;標為 quarantine,保留原始 row 與原因,交由可追溯的決策處理。 第三步做可重跑的 backfill。每個舊 row 產生穩定 (source_system, source_order_id) ,用 INSERT ... ON CONFLICT 避免重跑時新增第二筆。每個 batch 在 transaction 內寫入 order、items 和 mapping,任一 constraint 失敗就 rollback。批次大小應由實際鎖定時間與資料量測試決定,而不是引用通用數字。 第四步加入 shadow read 與雙寫。先讓新模型接收寫入,但使用者授權仍由舊路徑決定。對每次購買同時計算舊、新結果,只記錄差異,不立即改變權限。雙寫不能是兩個互不相干的 HTTP call;若無法共享 database transaction,就使用 outbox:業務交易先寫 order 與待送訊息,worker 再以 idempotency key 呼叫外部 provider。 第五步重建 webhook handler。必須先以原始 request body 和 endpoint secret 驗證 Stripe signature,驗證後才解析。嘗試 insert (provider, provider_event_id) ;若 conflict,代表已處理或正在處理,回傳成功但不重複發放權限。若兩個不同 Event 表達同一物件狀態,也要以 provider object ID 加 event type 做業務層去重。 建立 Checkout 或其他 Stripe POST 時,使用可對應內部 order 的 idempotency key。Stripe 文件指出,網路錯誤讓 client 不知道 request 是否抵達時,應用相同 key 與相同參數重試;改變參數時不能重用舊 key。內部 order_id 應放在 provider 支援的 reference/metadata,讓 webhook 能回查,而不是只相信 success URL。 第六步做 reconciliation 才切換 read path。逐 currency 比對 order 數、已付款數、退款數與金額總和;逐筆比對 provider reference、user entitlement、課程或方案。差異必須為零或有明確、已批准的 exception list。切換先從內部管理頁或低風險讀取開始,觀察後再把 entitlement source of truth 指向新模型。 失敗與復原 若 backfill 中斷,保留 migration run ID、最後成功 batch 與每批 hash。因為 source key 與 upsert 是穩定的,可以從最後確認點重跑;不要先刪除全部新資料。constraint error 進 quarantine,不可以把 NOT NULL 暫時移除後當作成功。 若雙寫出現差異,先關閉新模型的 read flag,讓舊系統繼續提供既有權限;停止會擴大差異的 writer,再用 outbox/event log 重播缺少的寫入。不要從 success page 補發購買,因為使用者可能關頁、刷新或偽造 request。 若 webhook signature 驗證失敗,回傳非成功並記錄 event request ID、時間與錯誤類型,但不要記錄 secret 或完整敏感 payload。輪替 webhook secret 時依 Stripe 的 endpoint 設定與有效期規劃重疊,並在 staging 用 Stripe CLI 或測試事件驗證。 若切換後 entitlement 錯誤,feature flag 應能立即把 read path 指回舊模型。回滾只改讀取來源,不刪新資料;保留新系統收到的事件,修復後才能 reconciliation 再切換。任何退款或爭議處理仍要維持單一路徑,避免兩邊各執行一次。 驗證指令 以下 SQL 是驗證形狀的範例,欄位應依實際 schema 調整。每次執行都要按 currency 分組,不能把不同貨幣直接相加: select currency, status, count(*) as orders, sum(amount_total) as amount from orders group by currency, status order by currency, status; select provider, provider_event_id, count(*) from payment_events group by provider, provider_event_id having count(*) > 1; 另外測試同一個 webhook 連續送兩次、兩個 handler 並行處理、Stripe POST timeout 後以相同 idempotency key 重試、退款後 entitlement 更新,以及切換 flag 前後同一 user 的權限結果。驗收證據要包含 migration count、quarantine count、reconciliation hash 與 rollback 演練結果,不應包含 secret。 官方來源 Stripe webhook 文件 Stripe advanced error handling 與 idempotency PostgreSQL INSERT / ON CONFLICT PostgreSQL transactions 延伸閱讀 在 技術文章 查看 Checkout、Auth 與 Edge Function 的配套實作。 透過 課程總覽 練習交易、constraint 與 webhook 測試。 複雜付款遷移可先從 顧問服務 做 schema 與回滾審查。