雲音樂中 In-App Purchase 實踐總結篇

雲音樂技術團隊發表於2023-05-18
本文作者:0linatan0

IAP主要說明

內購專案

開發者接入 IAP 時,需要按照蘋果提供的規範,根據 App 提供商品的功能和型別來選擇不同的內購專案型別,進行建立商品。相當於在我們業務服務端有一份商品列表,蘋果 AppStoreConnect 也有一份商品列表與之對應。目前 IAP 中內購專案分為四類:

  1. Consumable products (消耗型商品)

    • 比如:Look 直播中的音符
    • 同一個 AppleID 可以購買多次,即買即用
  2. Non-consumable products (非消耗型商品)

    • 比如:解鎖App中功能關卡
    • 同一個 AppleID 只能購買一次,再次購買會提示"已購買", 永久有效
  3. Auto-renewable subscriptions (自動續期訂閱)

    • 比如:雲音樂中黑膠會員連續包月
    • 同一 Apple ID 在購買時會檢查是否購買過,如果購買過並且還在續期許可權中,系統會提示已購買而無法再購買;如果購買過之後取消過,則可以再次購買
  4. Non-renewable subscriptions (非續期訂閱)

    • 比如: 月度/季度/年度 會員
    • 同一 Apple ID 可以購買多次,可以再次購買,權益受期限限制

IAP商品

建立管理IAP商品

選擇商品型別後,AppStore Connect 中建立商品,以消耗型商品建立為例,需要提供如下資訊:

  1. product identifier : 標識商品的ID

    • 在此應用下是唯一的,只要建立過即使刪除也會存在
  2. price : 根據蘋果提供的價格等級,不能隨意填寫金額

    • 會出現同一等級對應不同國家的 AppleID 賬號價格換算差異大
  3. 商品描述

    • 支援多種語言,會根據 AppleID 所在地區展示
  4. 截圖&操作路徑【送審需要】

具體操作手冊參見Create in-app purchases

專案實現IAP購買

開發者需要接入系統庫 StoreKit,蘋果在 WWDC21 推出新的 StoreKit2 支援購買,但其需要 iOS15 及以上才支援,目前我們專案中還是使用老的 StoreKit 。

對於 IAP 購買支付的過程是蘋果系統處理,只是在交易完成之後,更新本地的交易票據資訊並回撥 App (票據可以理解為包含交易支付相關資訊的加密資料),而對於這份資料是可能會重複或者偽造;需要對其進行驗證,蘋果提供兩種方式:本地驗證和服務端驗證;一般出於安全性和功能考慮會選用服務端驗證。服務端會拿著這份票據再去請求蘋果服務端,獲取交易支付的詳細資訊,根據資訊判斷處理履約情況。

流程圖

整體流程結構如下圖:
IAP互動流程圖

自動訂閱型別的商品因為涉及到下個週期代扣履約的情況,會多一些處理,一是服務端可以透過 App Store Server Notifications接收訂閱續期的情況;二是 App 在啟動時收到蘋果關於續期成功的票據更新回撥。

主體邏輯

  1. 透過ProductId請求獲取具體的商品資訊

    SKProductsRequest *productRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithObject:self.productIdentifier]];
    request.delegate = self;
    ....
    [request start];
    
  2. (void)productsRequest:(SKProductsRequest )request didReceiveResponse:(SKProductsResponse )response{....}
  3. (void)request:(SKRequest )request didFailWithError:(NSError )error{....}

IAP Product 是在 AppStoreConnect 中配置,是與我們的App對應。特別需要注意的是在測試包App被重簽名時,將會獲取不到對應的 IAP 商品資訊。

  1. 發起支付
SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:self.product];
payment.quantity = MAX(_quantity,1);
payment.applicationUsername = self.userIdentifier;
[[SKPaymentQueue defaultQueue] addPayment:payment];

IAP 支援批次購買,但支援的最大數量是 10 ,具體說明參見 SKMutablePayment——quantity

  1. 支付完成後,StoreKit 處理支付,返回此次交易資訊
//需要監聽Payment Queue,建議是在didFinishLaunchingWithOptions:時就增加監聽
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];

//處理回撥事件
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions
{
    for (SKPaymentTransaction *transaction in transactions)
    {
        switch (transaction.transactionState)
        {
            case SKPaymentTransactionStatePurchased:
                //購買完成...
                break;
            case SKPaymentTransactionStateFailed:
                //交易失敗...
                break;
            case SKPaymentTransactionStateRestored:
                //恢復交易...
                break;
            case SKPaymentTransactionStatePurchasing:
                //交易正在進行..
                break;
            default:
                break;
        }
    }
}
  1. 交易完成後,獲取小票資訊,請求服務端進行票據驗證
//獲取小票
NSData *receiptData = [NSData dataWithContentsOfURL:[[NSBundle mainBundle] appStoreReceiptURL]];

//請求服務端驗證
....

//交易完成
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];

服務端票據驗證

  1. 呼叫蘋果服務端的票據驗證介面
沙盒環境: https://sandbox.itunes.apple.com/verifyReceipt

正式環境: https://buy.itunes.apple.com/verifyReceipt

沙盒環境不需要真實購買,在 AppStoreConnect 建立沙盒測試賬號,可以模擬支付。

正式環境是針對 AppStore 上架的應用內購買,如果將沙盒環境小票傳送到正式環境驗證,會收到 21007 的 Status Code

  1. 請求引數格式
{
  "receipt-data":"xxxxx",   //客戶端本地的小票資料
  "password":"xxxxxx"       //可選,自動訂閱設定時在 AppStoreConnect 生成的金鑰(無自動訂閱時不需要)
}

可以看到驗證請求介面沒有過多限制,只要是真實的小票資料,就可以透過驗證介面請求返回結果,這也對服務端對票據結果的真實可靠性需要做完備的校驗

  1. 返回的結果
//消費型商品購買驗證結果
{
    "receipt": {
        "receipt_type": "Production",   //交易產生的環境
        "adam_id": 0,
        "app_item_id": 0,
        "bundle_id": "xxxxxxx",     //小票歸屬的 App bundleId
        "application_version": "0",
        "download_id": 0,
        "version_external_identifier": 0,
        "receipt_creation_date": "2023-02-22 11:02:52 Etc/GMT",
        "receipt_creation_date_ms": "1677063772000", //生成小票的時間戳
        "receipt_creation_date_pst": "2023-02-22 03:02:52 America/Los_Angeles",
        "request_date": "2023-02-24 04:20:38 Etc/GMT",
        "request_date_ms": "1677212438488",
        "request_date_pst": "2023-02-23 20:20:38 America/Los_Angeles",
        "original_purchase_date": "2022-12-16 05:46:18 Etc/GMT",
        "original_purchase_date_ms": "1671169578000",
        "original_purchase_date_pst": "2022-12-15 21:46:18 America/Los_Angeles",
        "original_application_version": "0",
        "in_app": [ //所有交易小票資訊
            {
                "quantity": "1",
                "product_id": "xxxxxxxxx.xxxx.xxxx",    //交易商品的識別符號
                "transaction_id": "470001434498518", //每次交易發生產的唯一識別符號
                "original_transaction_id": "470001434498518",//原始購買的交易識別符號,自動續費下次代扣發生交易,改址不變
                "purchase_date": "2023-02-22 11:02:52 Etc/GMT",
                "purchase_date_ms": "1677063772000", //購買時間戳
                "purchase_date_pst": "2023-02-22 03:02:52 America/Los_Angeles",
                "original_purchase_date": "2023-02-22 11:02:52 Etc/GMT",
                "original_purchase_date_ms": "1677063772000",
                "original_purchase_date_pst": "2023-02-22 03:02:52 America/Los_Angeles",
                "is_trial_period": "false",
                "in_app_ownership_type": "PURCHASED"
            }
        ]
    },
    "environment": "Production", //票據產生環境,Sandbox/Production
    "status": 0  //標識票據是否合法
}
//自動訂閱商品購買驗證結果
{
    "status": 0,
    "environment": "Production",
    "receipt": {
        "receipt_type": "Production",
        "adam_id": 0,
        "app_item_id": 0,
        "bundle_id": "xxxxxx",
        "application_version": "0",
        "download_id": 0,
        "version_external_identifier": 0,
        "receipt_creation_date": "2019-05-15 12:00:08 Etc/GMT",
        "receipt_creation_date_ms": "1557921608000",
        "receipt_creation_date_pst": "2019-05-15 05:00:08 America/Los_Angeles",
        "request_date": "2019-06-03 08:47:04 Etc/GMT",
        "request_date_ms": "1559551624568",
        "request_date_pst": "2019-06-03 01:47:04 America/Los_Angeles",
        "original_purchase_date": "2018-08-26 03:28:11 Etc/GMT",
        "original_purchase_date_ms": "1535254091000",
        "original_purchase_date_pst": "2018-08-25 20:28:11 America/Los_Angeles",
        "original_application_version": "0",
        "in_app": [{
            "quantity": "1",
            "product_id": "xxxxxxxxxxx",
            "transaction_id": "370000374840125",
            "original_transaction_id": "370000374840125",
            "purchase_date": "2019-05-15 11:59:38 Etc/GMT",
            "purchase_date_ms": "1557921578000",
            "purchase_date_pst": "2019-05-15 04:59:38 America/Los_Angeles",
            "original_purchase_date": "2019-05-15 11:59:40 Etc/GMT",
            "original_purchase_date_ms": "1557921580000",
            "original_purchase_date_pst": "2019-05-15 04:59:40 America/Los_Angeles",
            "expires_date": "2019-06-15 11:59:38 Etc/GMT",
            "expires_date_ms": "1560599978000",
            "expires_date_pst": "2019-06-15 04:59:38 America/Los_Angeles",
            "web_order_line_item_id": "370000115213929",
            "is_trial_period": "false",
            "is_in_intro_offer_period": "true"
        }]
    },
    "latest_receipt_info": [{ //除已完成的消費型商品以外的所有交易資訊
        "quantity": "1",
        "product_id": "xxxxxxxxx.xxxx.xxxx",
        "transaction_id": "370000374840125",
        "original_transaction_id": "370000374840125",
        "purchase_date": "2019-05-15 11:59:38 Etc/GMT",
        "purchase_date_ms": "1557921578000",
        "purchase_date_pst": "2019-05-15 04:59:38 America/Los_Angeles",
        "original_purchase_date": "2019-05-15 11:59:40 Etc/GMT",
        "original_purchase_date_ms": "1557921580000",
        "original_purchase_date_pst": "2019-05-15 04:59:40 America/Los_Angeles",
        "expires_date": "2019-06-15 11:59:38 Etc/GMT",
        "expires_date_ms": "1560599978000",
        "expires_date_pst": "2019-06-15 04:59:38 America/Los_Angeles",
        "web_order_line_item_id": "370000115213929",
        "is_trial_period": "false",
        "is_in_intro_offer_period": "true"
    }],
    "latest_receipt": "xxxxxxxxxxx latest_receipt_info xxxxxxxxxxxxx",  //只包含自動續費相關票據
    "pending_renewal_info": [{  //自動續費具體狀態和內容
        "auto_renew_product_id": "xxxxxxxxx.xxxx.xxxx",
        "original_transaction_id": "370000374840125",
        "product_id": "xxxxxxxxx.xxxx.xxxx",
        "auto_renew_status": "1"
    }]
}

所有欄位的含義可以參見App Store Receipts responseBody

可以看到返回結果中包含交易的詳細資訊,但沒有和我們 App 內部相關的,需要服務端解析這些資訊處理,將權益發放給使用者,因此也會產生較多的問題

主要問題

從上述流程中發現,IAP 商品交易支付是在系統內部流轉,對於 App 只有發起和交易結果回撥的感知,而最終交易結果需要依託客戶端像服務端發起票據驗證請求,獲取到結果再和自身服務做匹配履約;服務端無法主動向蘋果請求訂單結果。

因此在實際應用場景中會遇到各種問題:

  1. 向蘋果請求商品資訊獲取失敗

    • 一般是網路的原因,但是這種會導致使用者無法再進行下一步支付
    • 最佳化方法是請求到商品資訊,會進行快取,下一次支付直接獲取商品資訊
  2. 票據驗證請求慢,經常超時

    • 最佳化方式:服務端接入海外代理
  3. 蘋果交易和我們服務訂單號如何匹配

    • 客戶端會本地記錄 IAP 商品和訂單號的資料,當收到回撥時,根據交易中 ProductId 獲取對應的訂單號,一併帶到服務端請求驗證
    • 如果因為某些原因未獲取到訂單號,服務端可以根據票據交易資訊在訂單系統中向前回溯適用的訂單進行履約
  4. Apple 已扣款,但 App 中權益未到賬

    • 網路抖動、客戶端票據丟失無法向服務端發起請求驗證等情況都有可能導致該問題
    • 最佳化方式:

      • 客戶端獲取到小票交易資訊儲存本地,如果驗證未完成,定時向服務端發起驗證
      • 提供使用者手動發起驗證入口,重新整理本地小票資料,向服務端發起驗證
      • 完善每個階段的日誌,便於追溯交易行為
  5. 自動續費下個週期代扣問題

    • 有如下途徑可以讓服務端感知到扣費時間

      • 服務端可以透過 Apple Server-To-Server Notification 接收訊息
      • 客戶端收到 StoreKit 扣款成功回撥,帶上本地票據資訊請求服務端處理
    • 但因為服務端回撥有時不穩定以及依賴裝置開啟狀態,還有一種方式是服務端儲存已簽約使用者的小票資料,在到期前透過這批舊小票向蘋果服務端請求續費狀態

NEStoreKit

針對上述提到的問題進行解決,也伴隨著雲音樂多個產品線開發上線,接入 IAP 需求也在增加,因此我們開發了基礎庫 NEStoreKit,對業務流程進行抽象,方便各團隊快速接入;保障支付履約完成,完善交易場景,記錄各個階段交易日誌,對問題有效排查。

整體結構

整體結構

將 IAP 交易處理邏輯封裝在內部,回撥的交易資訊包裝成 Task,放入佇列中,依次交由 Verifier 請求服務端進行驗證。

SDK外部使用

//配置
NEStoreConfig *storeConfig = [NEStoreConfig new];
storeConfig.verifyRequestUrl = xxxx
//重試驗證回撥處理
storeConfig.silentVerifyCompletionBlock = ^(NEStorePaymentResult *paymentResult) {
 };
//取消購買回撥
storeConfig.cancelPaymentBlock = ^(NEStorePaymentResult *paymentResult, SKPaymentTransaction *transaction) {
    //...
};
[[NEStoreManager defaultManager] setConfig:storeConfig];

//發起購買呼叫
- (void)makePayment:(NSString *)productIdentifier
           quantity:(NSInteger)quantity
     userIdentifier:(nullable NSString *)userIdentifier
           userInfo:(nullable NSDictionary *)userInfo
            success:(nullable NEPaymentCompletionBlock)success
            failure:(nullable NEPaymentCompletionBlock)failure;

IAP票據結果的可靠性

  1. 沙盒環境權益發放的隔離

    • 稽核版本( TestFlight 包)App 執行的是正式環境,IAP內購走的是沙盒環境,不需要真實支付,會導致一批沒有真實支付的賬號兌現線上權益;
    • 需要對這部分票據驗證完成的權益發放進行限制,行為可追溯;非稽核期間關閉正式環境的沙盒校驗
  2. 票據結果解析的可靠性

    • 因為票據資訊依賴於客戶端發起請求,有機率會被假冒,服務端需要校驗結果合法性

      • bundle_id: 檢查是不是自家 App 產生票據(不同的 bundle_id 下是可以建立相同 product_id 內購專案,蘋果驗證請求只返回結果,不會做任何校驗)
      • 交易資訊的檢查

        • product_id 、purchase_date_ms : 和App端訂單系統比對 IAPProductId,下單時間
        • transaction_id 、original_transaction_id : 標識交易的唯一性(非自動訂閱在 restore 之後會生成新的交易,transaction_id 會更改,original_transaction_id 不變)
        • web_order_line_item_id:自動訂閱時才會生成,標識交易的唯一性(因為一份自動訂閱,original_transaction_id 是相同的,transaction_id 也會因為 restore 會生成不一樣,防止重複使用,只能用這個)
  3. 退款問題

  4. 現實應用中還會遇到其他各種問題,客戶端有詳盡的各階段日誌, 服務端保留上傳的小票資訊,風控處理,接入蘋果查詢支付相關的 API

StoreKit2

蘋果在WWDC2021提出的針對IAP的全新設計,Meet StoreKit 2

  1. 客戶端:API是使用Swift5.5特性 async/await 進行開發,iOS15及以上

    • 返回的ProductInfo資訊更全面

      • productType,subscription,jsonRepresentation
    • MakePayment時支援傳入 appAccountToken ,可以將AppleId和App中賬戶對應(不會像 applicationUserName 那樣容易丟了)
    • 蘋果自動校驗 Transaction 的合法性,但對於我們還是會需要透過服務端去校驗
    • 支援檢視歷史賬單:這個和設定裡看賬單歷史是對應的,但只能看非消耗型、訂閱和自動訂閱的
    • 支援檢視訂閱資訊:最近交易資訊,訂閱狀態,自動訂閱補充資訊
  2. 服務端

Origin StoreKit vs StoreKit2

  1. 所有交易資訊是互通的
  2. 原先老版本購買的,新版本可以獲取
  3. 新版本購買的,老版本可以獲取到

StoreKit2 提供的 API 使用更為簡單,對於客戶端來說可以用 appAccountToken 替換 applicationUserName ,將 AppleId 和 App 中賬戶對應,不會像之前容易丟失;同時服務端也可以透過這個標識將使用者的消費行為發給蘋果,協助蘋果處理使用者對消費型商品退款的情況。目前較大問題是iOS版本的限制。

最後

IAP的使用一直為開發者詬病,包括建立商品的流程繁瑣,以及剛開始接入自動續費時,踩了不少坑,在和蘋果開發人員交流和反饋中,蘋果逐漸為開發者提供了更多更全面的API,諸如呼叫介面管理 IAP 商品Create an In-App Purchase,服務端透過App Store Server API自主查詢交易資訊。作為iOS開發人員需要持續關注 StoreKit 的發展,與服務端交流,不斷完善交易系統的可靠和安全性。

參考連結

  1. App 內購買專案
  2. StoreKit——In-App Purchase
  3. Validating receipts with the App Store
  4. App Store Receipts responseBody
  5. App Store Server Notifications
本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們grp.musicfe(at)corp.netease.com!

相關文章