歡迎來到第二十天!今天迎來了我們**「階段三:系統平台整合與金流串接」**的最終篇章。
為了讓門市能開通線上預購禮包、購買遊戲內特色外觀,或是讓顧客在遊戲中兌換實體門市折價券,我們需要正式串接第三方金流服務(如綠界 ECPay / LINE Pay)。今天我們要深度解構金流開發中最核心的關鍵技術:Webhook 的冪等性(Idempotency)防重複刷扣與資料庫 ACID 事務(Transaction)。
當顧客完成信用卡付款或第三方支付後,金流服務商(如 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 資料庫]
1|OK 回應,它會自動重複發送相同訂單的通知 3~5 次!如果後端沒有處理好,就會導致玩家付一次錢,鑽石被重複發放 3 次的重大財務災難!為了確保「無論相同的 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")
}
}
}
Tech Tip:在電腦科學中,一個操作如果「執行 1 次」跟「執行 100 次」所產生的系統最終狀態與結果完全相同,該操作就具備 冪等性(Idempotency)。在金流 Webhook 處理中,冪等性就是系統防禦的生命線!透過資料庫的
UNIQUE CONSTRAINT、status狀態機判斷或是.forUpdate()行級鎖定,能徹底杜絕重複加點數或重複扣款的商業事故。