所有文章
Deep dive閱讀時間 8 分鐘

Apple 和 Google 都可能把同一筆退款向你的伺服器投遞不止一次,如果你對每一次都執行操作,重複的退款通知就會讓你付出代價

Apple 會把一條退款通知重試最多五次,而 Google Play 依託 Pub/Sub 的至少一次投遞,因此同一筆退款可能不止一次到達你的伺服器。以下講解如何處理重複的退款通知,同時不會扣錯餘額或重複消耗 API 配額。

許多完全相同的紙信封堆在深色桌面上,其中一個被抽到一旁,象徵著不斷到達你伺服器的重複退款通知

重點摘要

  • 只要你的伺服器沒有以 200 到 206 之間的 HTTP 狀態作答,Apple 就會把一條 App Store Server Notification V2 重試五次,分別在上一次嘗試之後的 1、12、24、48 和 72 小時。把第一次嘗試算在內,一筆退款最多可到達六次。
  • 每一次真正的 Apple 重試都攜帶相同的 notificationUUID,所以該欄位而非交易 id 才是你的去重鍵。
  • Google Play 的 Real-time Developer Notifications 依託 Cloud Pub/Sub,而它保證至少一次投遞且不保證順序,因此同一條訊息可能到達兩次或亂序到達。Google 告訴你,在處理任何東西之前先檢查 messageId 的唯一性。
  • Apple 週期性的 CONSUMPTION_REQUEST 通知不是重試。Apple 會在開放的退款視窗內持續發送全新的通知,每一條都帶有不同的 notificationUUID,因此按 notificationUUID 去重會正確地保留其中的每一條。
  • 同一個 Apple transactionId 可以攜帶不止一個決定,例如先是一條 REFUND_DECLINED,隨後又是一條 REFUND,所以僅按交易 id 去重會丟掉一個你需要的獨立事件。
  • 透過返回 4xx 或 5xx 來拒絕重複只會讓商店再次重試。請在你自己的資料庫內部去重,並且始終返回成功狀態。
  • 一個不具冪等性的退款處理器會在第二次投遞時重複執行操作。它會把餘額扣兩次、把付款反轉兩次,或者為一筆它已經關閉的退款重新核對,從而消耗計費的 Play Developer 和 App Store Server API 配額。

你的伺服器會不止一次收到同一個退款事件,而且兩家商店都是有意這樣設計的。當你的端點沒有乾淨地作答時,Apple 會把一條 App Store Server Notification 重試最多五次。Google Play 透過 Cloud Pub/Sub 投遞它的 Real-time Developer Notifications,而它承諾至少一次投遞,對順序則不作任何承諾。所以問題從來不是重複是否會到達。問題是當你的程式碼第二次看到同一筆退款時它會做什麼。這一點做錯了,你就會把餘額扣兩次、把付款反轉兩次,或者為一筆你已經關閉的退款重新核對而消耗計費的 API 配額。以下講解重複的退款通知實際上是如何到達你的,哪些重複是真正的重複而哪些只是看起來像,以及如何處理它們,好讓第二次投遞不花任何代價。

一條退款通知是至少投遞一次,這和恰好一次並不相同

兩家商店都把一條已投遞的通知當作它們會不斷嘗試兌現的承諾,而不是發出即忘的一次性動作。這對可靠性是好事,因為你在部署期間錯過的一條通知稍後仍會到達你。這對正確性卻是陷阱,因為保證你最終能拿到事件的那套機制,也保證了你有時會拿到它兩次。你的處理器必須具備冪等性,也就是說同一筆退款的第二次和第三次投遞不會改變任何第一次尚未改變的東西。

Apple 會在三天內重試五次

當 Apple 發送一條 App Store Server Notification V2 時,它期望你的伺服器以 200 到 206 範圍內的 HTTP 狀態作答。其他任何東西,無論是 4xx 還是 5xx,都告訴 Apple 投遞失敗,於是 Apple 就會重試。這個時間表是固定的:五次重試,分別在上一次嘗試之後的 1、12、24、48 和 72 小時。把第一次嘗試算在內,一個退款事件最多可到達六次,分佈在大約一週裡。這些重試中的每一次都攜帶相同的 notificationUUID。該欄位就是你的去重鍵。如果你已經記錄過某個 notificationUUID,你手上這次投遞就是一次重複,正確的回應是不存任何新東西,並且仍然返回 200。

Google Play 依託 Pub/Sub,它承諾至少一次且對順序不作任何說明

Google Play 的 Real-time Developer Notifications 被發布到一個 Cloud Pub/Sub 主題。Pub/Sub 的投遞保證是至少一次,而且它根本不作任何順序保證。這意味著同一條訊息可能被向你的端點投遞不止一次,而針對同一筆購買的兩條訊息也可能亂序到達。Google 自己的指引說得很明確:解包 base64 的 data 欄位,讀取 messageId,並在處理任何東西之前檢查你此前沒有見過它。重複的 messageId 是你要跳過的一次重複。關於同一筆購買的兩條不同通知仍應落在同一條記錄上,所以也要把你儲存的狀態鍵在 purchaseToken 上,並讓較晚的事件更新較早的事件所建立的那一行。

平台投遞模型去重依據成功訊號若你不確認
App Store Server Notifications V2最多 6 次嘗試:第一次,加上在 1、12、24、48、72 小時的 5 次重試notificationUUIDHTTP 200 到 206Apple 按固定時間表重試,然後停止
Google Play RTDN 透過 Pub/Sub至少一次,不保證順序Pub/Sub messageId,實體鍵在 purchaseToken 上對 push 返回 HTTP 200,或一次顯式的 ackack 截止時間過後 Pub/Sub 會重發

那些並非重複的重複

並非每一條看起來像你見過的通知都是一次重試。Apple 的兩種行為會發送真正全新的事件,它們共享一筆購買但必須各自被處理,用一套天真的去重把它們摺疊掉會丟掉你需要的資訊。

Apple 發送的是全新的 CONSUMPTION_REQUEST,而非重試

在一個消耗型商品處於開放退款請求期間,Apple 不會只發一條 CONSUMPTION_REQUEST 然後等待。它會在整個開放退款視窗內週期性地發送新的,直到退款被關閉。Apple 員工已經確認這些不是重試,而線索就在你用來去重的那個欄位上:每一條全新的 CONSUMPTION_REQUEST 都攜帶不同的 notificationUUID。所以一套鍵在 notificationUUID 上的去重會自動做對的事。它摺疊掉真正的重試,並保留每一條獨立的提示。你絕不能做的,是按交易 id 和通知類型去重,因為那會讓第一條之後的每一條 CONSUMPTION_REQUEST 都被壓掉,讓你在被丟掉的那些上損失 12 小時的證據視窗。

一筆交易可以攜帶不止一個決定

單個 transactionId 在其生命週期內可以產生不止一個退款結果。Apple 可以發送一條 REFUND_DECLINED,隨後再為同一筆交易發送一條 REFUND,而且有開發者反映為一個交易 id 收到三條或更多與退款相關的通知。每一條都是帶有自身 notificationUUID 的獨立事件。如果你的去重鍵是交易 id,第二個決定看起來就像第一個的重複,你就永遠不會得知退款最終被批准了。交易 id 是把事件分組。它並不標識它們。

一隻機械夾爪把一個重複的包裹從輸送帶上抬起放進一旁的料箱,象徵著對重複退款通知的去重

一次重複實際上會讓你付出什麼

一條退款通知不是一盞狀態燈。它會觸發真實的操作:你撤銷存取權限,你扣除消耗型餘額,你反轉一筆創作者付款,你呼叫 App Store Server API 或 Play Developer API 去確認狀態。對一次重複再執行其中任何一項,代價都是真實的。

把錢的流向走一遍。撤銷存取權限兩次是無害的,因為存取權限已經沒有了。扣兩次餘額則不然:一個買了一包金幣又退款的使用者可能被扣到負餘額,然後你的支援團隊不得不手工去糾正。反轉兩次付款會把你已經退還過一次的錢又追回來,現在你欠一位創作者一句道歉和一次更正。而你對某個商店 API 重新處理的每一次重複,都會花掉 Google 明確警告你要保護的配額,所以一場故障期間的一陣 Pub/Sub 重發,可能在你最承受不起的那一天把你推入限流。

退款證據視窗在 Apple 一側把賭注抬高了。如果一套天真的去重把 Apple 在開放退款視窗內發送的那些重複的 CONSUMPTION_REQUEST 壓掉了,你就可能錯過你本需要作答的那一條,而一條你沒有在 12 小時內作答的 CONSUMPTION_REQUEST,往往會被 Apple 預設批准退款。那不是一次重複扣款。那是一筆丟失的銷售,外加你在交付這筆購買時已經花掉的算力、API 呼叫、儲存和付款,而退款沒有把這些中的任何一樣還給你。

失敗模式出了什麼問題它讓你付出什麼
對一次重複的 REFUND 再扣一次餘額使用者的消耗型餘額變成負數用於對帳的人工支援時間,以及糟糕的客戶體驗
把付款反轉兩次你把已經退還過一次的錢又追回來一次創作者更正和一次帳務清理
對某個商店 API 重新處理重複的呼叫消耗 Play Developer 或 App Store Server API 配額在引發重發的那場故障期間遭遇限流
對 CONSUMPTION_REQUEST 過度去重你把一條獨立的退款提示當作假重複丟掉錯過一個 12 小時視窗,於是 Apple 預設批准退款

如何在不重複執行操作的前提下處理重複的退款通知

兩家商店的模式是相同的,只是鍵不同。記錄這次投遞,在執行操作之前檢查鍵,只執行一次,並且始終告訴商店你收到了。

  • 按商店的投遞 id 去重,而非交易。Apple 用 notificationUUID,Google Play 用 Pub/Sub 的 messageId。把它帶著唯一約束儲存,這樣並發的重複就會在競態中落敗,而不會重複執行操作。
  • 讓下游操作在它自身的意義上具備冪等性。鍵在投遞 id 上能阻止重新處理,但也要把效果寫成:撤銷、扣除或反轉在執行前先檢查當前狀態,並且可以安全地執行兩次。
  • 先持久化,再確認。在你返回 200 或 ack 那條 Pub/Sub 訊息之前,先把事件寫入你的資料庫。如果你先 ack 而寫入失敗了,商店會認為這條訊息已投遞並且永遠不會再發送,現在你就把它徹底弄丟了。
  • 始終返回成功狀態,即使是對一次重複。對 Apple 返回 200 到 206,對 Google 對那條 Pub/Sub push 返回 200。用錯誤拒絕一次重複只會讓商店再次重試它。
  • 在實體上分組,在事件上標識。把你儲存的購買狀態鍵在 purchaseTokenoriginalTransactionId 上,好讓亂序的投遞更新同一行,但把每個 notificationUUIDmessageId 當作它自己的事件,因為一筆購買合理地會產生若干個。

在你信任你的退款 webhook 之前的一份簡短清單

  • Apple 的投遞按 notificationUUID 去重,一次重複不寫任何新東西但仍返回 200。
  • Google Play 的投遞按 Pub/Sub 的 messageId 去重,在任何處理之前進行檢查。
  • 購買狀態鍵在 purchaseTokenoriginalTransactionId 上,好讓亂序的事件落在同一條記錄上。
  • 每一個退款副作用,撤銷、扣除或反轉,都可以安全地執行不止一次。
  • 你的處理器在確認之前寫入事件,而絕不在之後。
  • 重複的 CONSUMPTION_REQUEST 被當作獨立的提示,而非重複,這樣就沒有任何開放退款視窗被丟掉。

有意地把一個重複穿過你自己的 webhook,看著它在第二次什麼都不改變。這就是全部的測試。一個可以安全地被打兩次的退款處理器,就是你在商店決定打它六次的那一刻起可以不再操心的那種。

常見問題解答

為什麼我的伺服器會不止一次收到同一條 App Store 退款通知?
因為只要你的伺服器沒有以 200 到 206 之間的 HTTP 狀態回應,Apple 就會把一條 App Store Server Notification V2 重試最多五次,分別在上一次嘗試之後的 1、12、24、48 和 72 小時。每一次重試都攜帶相同的 notificationUUID,所以你可以識別並跳過它。
我應該用哪個欄位來對 App Store Server Notifications 去重?
用 notificationUUID。一次真正的重試總是重複相同的 notificationUUID,而每一個真正全新的事件,包括每一條全新的 CONSUMPTION_REQUEST,都會拿到不同的一個,所以按 notificationUUID 去重能跳過重複而不丟掉獨立的事件。
重複的 CONSUMPTION_REQUEST 通知是我應該忽略的重複嗎?
不是。Apple 會在開放退款視窗內週期性地發送新的 CONSUMPTION_REQUEST 通知,而 Apple 員工確認這些不是重試。每一條都有自己的 notificationUUID,所以要處理每一條。丟掉它們有可能讓你錯過 Apple 給你作答的 12 小時視窗。
我該如何對 Google Play 的 Real-time Developer Notifications 去重?
從每條通知中讀取 Pub/Sub 的 messageId,並在執行操作之前把它和你已經處理過的那些對照檢查,因為 Pub/Sub 至少投遞一次,可能把同一條訊息發送不止一次。Google 明確推薦這樣做,以避免重複處理和浪費 API 配額。
我應該返回一個錯誤來拒絕一條重複的退款通知嗎?
不應該。返回 4xx 或 5xx 會告訴商店投遞失敗,於是它照樣重試。請在你自己的資料庫內部去重,並且始終返回成功狀態,對 Apple 用 HTTP 200 到 206,對 Google Play 的 push 用一次 200 確認。

資料來源與延伸閱讀

RefundHalt

App Store 與 Google Play 的退款自動駕駛

繼續閱讀

下一筆退款申請已經在路上。

讀完另一封關於未能抗辯退款的客服郵件所需的時間,就足夠您設定好 RefundHalt。