iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Vibe Coding

上岸用AI,看小白如何從無到有的用Vibe Codeing開發遊戲系列 第 20 篇

Day 20:實體門市金流串接 —— ECPay Webhook 冪等性與 ACID 事務處理實作

  • 分享至 

  • xImage
  •  

歡迎來到第二十天!今天迎來了我們**「階段三:系統平台整合與金流串接」**的最終篇章。

為了讓門市能開通線上預購禮包、購買遊戲內特色外觀,或是讓顧客在遊戲中兌換實體門市折價券,我們需要正式串接第三方金流服務(如綠界 ECPay / LINE Pay)。今天我們要深度解構金流開發中最核心的關鍵技術:Webhook 的冪等性(Idempotency)防重複刷扣與資料庫 ACID 事務(Transaction)。


1. 金流 Webhook 系統架構與 Race Condition 挑戰

當顧客完成信用卡付款或第三方支付後,金流服務商(如 ECPay)會以非同步(Asynchronous)方式發送 HTTP POST Request 到我們的 Ktor Webhook Endpoint。

[顧客手機] ──(1) 信用卡/LINE Pay 付款 ──> [第三方金流平台]
                                              │
                                       (2) 伺服器非同步通知 (Webhook POST /api/v1/payment/webhook)
                                              │
                                              ▼
                                    [Kotlin Ktor 後端 API]
                                              │
                                    (3) 檢查 HMAC 簽章與冪等性
                                    (4) 開啟 ACID 事務 (Exposed Transaction)
                                    (5) 發放鑽石/道具與更新訂單狀態
                                              │
                                              ▼
                                    [PostgreSQL 資料庫]

金流 Webhook 最常遇到的風險:

  • 重複通知(Retries):如果網路延遲,金流平台沒在特定時間內收到我們的 1|OK 回應,它會自動重複發送相同訂單的通知 3~5 次!如果後端沒有處理好,就會導致玩家付一次錢,鑽石被重複發放 3 次的重大財務災難!

2. Ktor 冪等性(Idempotency)與資料庫 ACID 事務寫碼實作

為了確保「無論相同的 Webhook 通知發送多少次,系統狀態只改變一次」,我們採用 PostgreSQL 資料庫級別的行鎖(Row Lock)與狀態機:

// PaymentWebhookRoute.kt
fun Route.paymentWebhookRouting() {
    post("/api/v1/payment/ecpay_webhook") {
        val params = call.receiveParameters().toMap().mapValues { it.value.first() }
        val receivedMac = params["CheckMacValue"] ?: return@post call.respond(HttpStatusCode.BadRequest, "0|No CheckMacValue")

        // 1. 驗證金流簽章 CheckMacValue (HMAC-SHA256 防止偽造請求)
        val hashKey = System.getenv("ECPAY_HASH_KEY") ?: "test_key"
        val hashIV = System.getenv("ECPAY_HASH_IV") ?: "test_iv"
        
        val sortedParamsStr = params.filterKeys { it != "CheckMacValue" }
            .toSortedMap(String.CASE_INSENSITIVE_ORDER)
            .map { "${it.key}=${it.value}" }
            .joinToString("&")
            
        val raw = "HashKey=$hashKey&$sortedParamsStr&HashIV=$hashIV"
        val urlEncoded = URLEncoder.encode(raw, "UTF-8").lowercase()
            .replace("%2d", "-").replace("%5f", "_").replace("%2e", ".").replace("%21", "!")
            .replace("%2a", "*").replace("%28", "(").replace("%29", ")")
            
        val calculatedMac = MessageDigest.getInstance("SHA-256")
            .digest(urlEncoded.toByteArray())
            .joinToString("") { "%02X".format(it) }

        if (!calculatedMac.equals(receivedMac, ignoreCase = true)) {
            return@post call.respond(HttpStatusCode.BadRequest, "0|Invalid Signature")
        }

        // 2. 簽章成功,執行具備冪等性 (Idempotent) 的 ACID 數據庫事務
        val tradeNo = params["MerchantTradeNo"] ?: return@post call.respond(HttpStatusCode.BadRequest, "0|No TradeNo")
        val rtnCode = params["RtnCode"] // "1" 代表付款成功

        var isHandled = false

        if (rtnCode == "1") {
            transaction {
                // 使用 forUpdate() 鎖定該訂單資料行 (Pessimistic Row Lock 防止併發 Race Condition)
                val order = OrderTable.select { OrderTable.tradeNo eq tradeNo }.forUpdate().singleOrNull()

                if (order != null) {
                    // 冪等性核心判斷:若訂單狀態已是 SUCCESS,代表先前已處理過,直接跳過發放!
                    if (order[OrderTable.status] == "SUCCESS") {
                        println("訂單 $tradeNo 已於先前處理完成,觸發冪等防護。")
                        isHandled = true
                        return@transaction
                    }

                    // 3. 更新訂單狀態為 SUCCESS
                    OrderTable.update({ OrderTable.tradeNo eq tradeNo }) {
                        it[status] = "SUCCESS"
                        it[completedAt] = java.time.Instant.now()
                        it[platformTradeNo] = params["TradeNo"]
                    }

                    // 4. atomically 發放遊戲鑽石/點數給會員
                    val grantedGems = 150L // 依據訂單商品發放
                    GameStateTable.update({ GameStateTable.userId eq order[OrderTable.userId] }) {
                        it[gems] = gems + grantedGems
                    }

                    println("訂單 $tradeNo 扣款成功,已發放 $grantedGems 鑽石給 User: ${order[OrderTable.userId]}")
                    isHandled = true
                }
            }
        }

        if (isHandled) {
            call.respondText("1|OK") // 綠界規定成功必須回應 1|OK
        } else {
            call.respondText("0|Order Not Found or Failed")
        }
    }
}

💡 鐵人賽小知識:什麼是「冪等性(Idempotency)」?在金流系統中為何是生命線?

Tech Tip:在電腦科學中,一個操作如果「執行 1 次」跟「執行 100 次」所產生的系統最終狀態與結果完全相同,該操作就具備 冪等性(Idempotency)。在金流 Webhook 處理中,冪等性就是系統防禦的生命線!透過資料庫的 UNIQUE CONSTRAINT、status 狀態機判斷或是 .forUpdate() 行級鎖定,能徹底杜絕重複加點數或重複扣款的商業事故。



上一篇
Day 19:門市離線收益演算法與雙向狀態同步 —— Server-Authoritative 時間戳驗證與 WebSocket 心跳包
下一篇
Day 21:階段三總結與 21 天心路歷程 —— 從跨界小白到 Vibe Coding 的破局之旅
系列文
上岸用AI,看小白如何從無到有的用Vibe Codeing開發遊戲 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言