Stories

Detail Return Return

蘋果內購 V1 與 V2 支付流程對比(附示例java 8+代碼) - Stories Detail

蘋果內購 V1 與 V2 支付流程對比(附示例java 8+代碼)

官方文檔:https://developer.apple.com/documentation/appstoreserverapi

國內大部分開發者對 微信支付、支付寶支付 的流程都比較熟悉,其典型的三步為:

  1. 客户端調用服務端預下單接口
    服務端會創建訂單並調用微信/支付寶的下單 API,獲取支付數據。
  2. 客户端拉起支付
    客户端使用第一步返回的支付數據,調起微信/支付寶進行支付。
  3. 異步通知 + 業務處理
    用户支付完成後,微信/支付寶的服務器會向我們的服務端發起異步回調,通知支付結果,服務端確認後完成發貨。

蘋果內購第一版本(V1)

在 V1 中,蘋果內購的支付邏輯與微信、支付寶有明顯不同:

  1. 添加內購商品
    在蘋果開發者後台(App Store Connect)配置商品 ID、價格等。
  2. 客户端直接發起支付
    客户端展示商品列表,用户選擇商品後,直接調用蘋果內購 API 發起支付(不需要預下單接口)。
  3. 收據驗證

    • 支付成功後,蘋果返回一個 支付憑證(receipt-data) 給客户端。
    • 客户端需要將憑證上傳到服務端。
    • 服務端再調用蘋果的 verifyReceipt 接口驗證訂單是否真實有效,完成支付流程。

⚠️ 問題:沒有標準化異步回調,容易掉單

  • 蘋果在 V1 時代幾乎沒有完善的異步通知機制(只有訂閲類有 Server Notification V1,且字段混亂不標準)。
  • 這意味着:

    • 如果客户端因網絡問題未上傳收據,服務端就無法感知訂單 → 容易掉單。
    • 如果客户端惡意攔截上傳,服務端也會遺漏訂單。
  • 因此,V1 的支付鏈路依賴客户端可靠上傳憑證,存在較大業務風險。

蘋果內購第二版本(V2)

在 V2(App Store Server API + Server Notification V2)中,蘋果對支付流程做了升級,更加接近微信/支付寶的邏輯:

  1. 添加內購商品
    在蘋果後台配置商品 ID、價格等。
  2. 客户端調用服務端預下單接口

    • 客户端下單時,先請求我們的服務端。
    • 服務端生成一條訂單記錄,並生成一個 UUID 格式的唯一標識(例如 123e4567-e89b-12d3-a456-426614174000)。
    • 注意區別:

      • 微信/支付寶的預下單是調用官方 API 獲取支付參數;
      • 蘋果內購的預下單不需要和蘋果交互,僅返回一個 UUID 給客户端 (蘋果官方強制要求為UUID格式),iOS客户端用此UUID和商品 ID 一起發起支付。
    • 如果系統的訂單號不是 UUID,需要額外生成一個 UUID 與訂單號關聯。
  3. 支付驗證 + 異步回調

    • 客户端完成支付後,蘋果返回一個 支付憑證,客户端將其傳給服務端進行驗證;
    • 同時,如果在蘋果後台配置了 Server Notification V2 回調地址,蘋果也會主動將訂單憑證推送給服務端;
    • 服務端要有分佈式鎖或冪等控制,客户端上傳憑證或蘋果回調任意一方先到,均可完成發貨,避免掉單。

⚠️ 蘋果 V2 異步回調的特殊點

  • 回調地址 只能在蘋果後台配置,不能像微信/支付寶那樣在每次下單時自由指定。
  • 只能配置一個回調地址:

    • 正式環境下通常配置生產環境域名;
    • 如果已經上線,回調域名配的是正式服的,在測試環境進行支付,回調依然會打到生產環境 → 生產環境無法匹配到訂單,需要額外邏輯來區分(這一點需要自行編碼驗證後才能更深入瞭解)。

V1 與 V2 的流程對比

特性 V1(舊版) V2(新版)
憑證獲取 客户端支付後獲取 receipt(Base64) 客户端支付後獲取 transactionId
驗證方式 客户端上傳 receipt 到服務端 → 服務端調用 Apple 接口驗證 服務端通過 transactionId 調用 Apple Server API 查詢訂單
異步通知 ❌ 不支持 ✅ 支持,Apple 會推送 signedPayload
環境區分 production / sandbox production / sandbox + Apple TestFlight
冪等機制 需自行實現,難度大 transactionId 唯一,全局可冪等
風險點 客户端可偽造請求 → 容易被破解 服務端直連 Apple 驗證 + JWT 簽名,安全性更高

👉 可以看出,V2 的設計更標準化,和微信/支付寶非常接近。


V2 訂單驗證流程

整體步驟如下:

  • 客户端調用服務端預下單接口,生成訂單信息並返回UUID給客户端
  • 客户端支付完成 → 獲取 transactionId
  • 客户端上傳 transactionId 給服務端
  • 服務端調用 Apple Server API 驗證
  • 服務端處理支付邏輯(冪等控制)
  • Apple 異步通知(signedPayload),再次確認交易狀態(異步通知與客户端上傳transactionId可能同時進行,需分佈式鎖做好冪等性)

接下來將用代碼來演示蘋果內購V2版本如何驗證客户端上傳的支付憑據和異步回調的支付結果

準備工作(除了rootCAG2和rootCAG3可自己下載,其餘參數登錄蘋果控制枱獲取即可)

keyId: 在蘋果控制枱獲取的keyId,格式如6G8VD0TVY
issuerId: 在蘋果控制枱獲取的issuerId,格式為UUID,123e4567-e89b-12d3-a456-426614174000
bundleId: 自己蘋果app的包名,如com.xxx.xxx
signingKey: 在蘋果控制枱下載的密鑰,如SubscriptionKey_6G8VD0TVY.p8
appAppleId: 在蘋果控制枱獲取的appAppleId
#以下兩個文件直接下載,公共文件,不是每個蘋果賬户獨有的,訪問地址:https://www.apple.com/certificateauthority/
rootCAG2: 下載地址:https://www.apple.com/certificateauthority/AppleRootCA-G2.cer
rootCAG3: 下載地址:https://www.apple.com/certificateauthority/AppleRootCA-G3.cer

java 11+ 引入以下依賴(查看git地址,獲取最新版本)

<dependency>
    <groupId>com.apple.itunes.storekit</groupId>
    <artifactId>app-store-server-library</artifactId>
    <version>3.5.0</version>
</dependency>

git地址:https://github.com/apple/app-store-server-library-java

java 8+ 引入以下依賴(查看git地址,獲取最新版本)

<dependency>
    <groupId>io.github.andyoconnor</groupId>
    <artifactId>app-store-server-library-java8</artifactId>
    <version>3.5.2-RELEASE</version>
</dependency>

git地址:https://github.com/AndyOConnor/app-store-server-library-java8

本次演示環境:jdk 17,springboot 3.2.0,maven 3.9

1、ios客户端調用預下單接口獲取UUID後,會調用蘋果內購的sdk進行支付,用户支付完成時,蘋果會回調給客户端支付憑證,其中有個transactionId字段,是蘋果的內購單號,需要傳給服務端

2、服務端拿到transactionId後,開始校驗

import com.apple.itunes.storekit.client.AppStoreServerAPIClient;
import com.apple.itunes.storekit.client.APIException;
import com.apple.itunes.storekit.client.AppStoreServerAPIClient;
import com.apple.itunes.storekit.model.Environment;
import com.apple.itunes.storekit.model.TransactionInfoResponse;
import com.apple.itunes.storekit.model.*;
import com.apple.itunes.storekit.verification.SignedDataVerifier;

// 方法示例
public String getAppAccountToken(String transactionId, Environment environment) {

        // 上述配置的值
        String issuerId = "123e4567-e89b-12d3-a456-426614174000";
        String keyId = "6G8VD0TVY";
        String bundleId = "com.xxx.xxx";
        Path filePath = Path.of("/path/to/key/SubscriptionKey_6G8VD0TVY.p8");
        String encodedKey = Files.readString(filePath);
        Set<InputStream> rootCAs = Set.of(
                new FileInputStream("/path/to/rootCAG2"),
                new FileInputStream("/path/to/rootCAG3")
        );
        String appAppleId = "";

        try {
            AppStoreServerAPIClient client = new AppStoreServerAPIClient(encodedKey, keyId, issuerId, bundleId,  environment);

            TransactionInfoResponse transactionInfo = client.getTransactionInfo(transactionId);

            SignedDataVerifier signedPayloadVerifier = new SignedDataVerifier(
                rootCAs, bundleId, appAppleId, environment, true
            );

            JWSTransactionDecodedPayload decodedPayload = signedPayloadVerifier.verifyAndDecodeTransaction(transactionInfo.getSignedTransactionInfo());

            // 這個是預下單時服務端傳給ios客户端的UUID
            return decodedPayload.getAppAccountToken().toString();
        } catch (APIException e) {
            long errorCode = e.getApiError().errorCode();

            // 錯誤碼'4040010'表示訂單不存在。
            // 由於客户端傳過來的transactionId,服務端並不知道是沙箱環境還是正式環境,這裏返回空字符串。
            // 外部第一次調用可傳入Environment.PRODUCTION正式環境,返回空字符串時,可繼續傳入Environment.SANDBOX,
            // 如果還是空字符串,就説明訂單號不是蘋果訂單號
            if (errorCode == 4040010L) {

                return "";
            }

            // 打印日誌方便排查問題
            log.error("Apple getAppAccountToken error, errorCode: {}, transactionId: {}", errorCode, transactionId, e);

            throw new RuntimeException(e);
        } catch (Exception e) {
            log.error("Apple getAppAccountToken error, transactionId: {}", transactionId, e);

            throw new RuntimeException("Apple verify order fail", e);
        }
    }

    // 蘋果的沙箱環境跟我們的測試環境不一樣,ios客户端分為測試包與正式包,測試包只能用沙箱環境支付,
    // 正式包可以用正式環境支付,如果在蘋果控制枱配置了測試號,用該測試號登錄蘋果手機,在正式包環境支付時,返回的憑證中,環境是沙箱環境,
    // 所以一個訂單號有可能是正式環境的,也可能是沙箱環境的,需要校驗兩次
    public void verifyOrder(String transactionId) {
         // 一般情況下,當app上架運營時,正式環境支付的比例要大於沙箱環境。
         // 優先用正式環境校驗,基本上都會有值,不會再一次調用沙箱環境,除非是我們自己測試的訂單,佔比較少
         String orderUUID = getAppAccountToken(transactionId, Environment.PRODUCTION);

         // 如果正式環境沒拿到數據,繼續傳入沙箱環境
         if (orderUUID == null || "".equals(orderUUID)) {
            orderUUID = getAppAccountToken(transactionId, Environment.SANDBOX);
         }

         if (orderUUID == null || "".equals()) {
           log.error("Apple verifyOrder error, transactionId: {}", transactionId);
           throw new RuntimeException("Apple verify order fail");
         }

         // 拿到UUID後,就可以像微信/支付寶的異步回調一樣,查詢我們的訂單,執行發貨發幣等業務邏輯
    }

異步回調

蘋果的異步回調中,會以post的方式,帶上signedPayload參數,該參數的值是一長串加密後的支付憑證

{
   "signedPayload": ""
}

獲取到參數後,直接解密

import com.apple.itunes.storekit.model.NotificationTypeV2;
import com.apple.itunes.storekit.model.*;
import com.apple.itunes.storekit.verification.SignedDataVerifier;

// 方法示例
public void getDecodedNotificationPayload(String signedTransactionInfo) {
        
        // 上述配置的值
        String issuerId = "123e4567-e89b-12d3-a456-426614174000";
        String keyId = "6G8VD0TVY";
        String bundleId = "com.xxx.xxx";
        Path filePath = Path.of("/path/to/key/SubscriptionKey_6G8VD0TVY.p8");
        String encodedKey = Files.readString(filePath);
        Set<InputStream> rootCAs = Set.of(
                new FileInputStream("/path/to/rootCAG2"),
                new FileInputStream("/path/to/rootCAG3")
        );
        String appAppleId = "";
        // 在開發調試期間,如果在蘋果控制枱配置的是測試服的鏈接,則使用沙箱環境,上線後要使用正式環境
        Environment environment = Environment.PRODUCTION

        try {
            SignedDataVerifier signedPayloadVerifier = new SignedDataVerifier(
                rootCAs, bundleId, appAppleId, environment, true
            );

            ResponseBodyV2DecodedPayload decodedPayload = signedPayloadVerifier.verifyAndDecodeNotification(signedTransactionInfo);
            
            // 這裏獲取到的交易信息與上面的的訂單校驗數據一致,調用上面訂單驗證的方法
            String transactionInfo = decodedPayload.getData().getSignedTransactionInfo();
            // 這是預下單時傳給蘋果的UUID,拿到後就可以查詢出訂單進行發貨發幣等業務了
            String appAccountToken = getAppAccountToken(transactionInfo, environment);

            // 蘋果的異步回調中,type有很多種類型,可以查看文章開頭的官方文檔瞭解,
            // 或者查看NotificationTypeV2源碼
            NotificationTypeV2 notificationType = decodedPayload.getNotificationType();

            // 不同的回調類型處理不同的業務邏輯,這裏強烈建議使用策略模式,在我的設計模式專欄中最後一篇文章有介紹
            // 支付下單和異步回調的策略模式用法,可參閲            
            if (notificationType == NotificationTypeV2.ONE_TIME_CHARGE) {
               // 這裏是一次性內購的回調
            } elseif(notificationType == NotificationTypeV2.REFUND) {
               // 這裏是退款的回調
            }
           
        } catch (Exception e) {
            log.error(
                "{}->getDecodedNotificationPayload error, environment: {}, signedTransactionInfo: {}",
                getClass().getSimpleName(), environment.name(), signedTransactionInfo, e);

            throw new RuntimeException(e);
        }
    }

附:設計模式專欄(五):設計模式在實際項目中的應用 —— 支付系統擴展與回調處理案例

總結:

藉助蘋果的app-store-server-library java sdk,實際上用幾行代碼就能解密和驗證蘋果的支付憑證,難點在於瞭解蘋果內購V2的支付流程,以及事先需要準備的參數,以下是本次流程和代碼的總結:

1、在蘋果控制枱配置產品id、價格等商品信息
2、在蘋果控制枱以及相應網站複製對應參數和下載對應文件

keyId: 在蘋果控制枱獲取的keyId,格式如6G8VD0TVY
issuerId: 在蘋果控制枱獲取的issuerId,格式為UUID,123e4567-e89b-12d3-a456-426614174000
bundleId: 自己蘋果app的包名,如com.xxx.xxx
signingKey: 在蘋果控制枱下載的密鑰,如SubscriptionKey_6G8VD0TVY.p8
appAppleId: 在蘋果控制枱獲取的appAppleId
#以下兩個文件直接下載,公共文件,不是每個蘋果賬户獨有的,訪問地址:https://www.apple.com/certificateauthority/
rootCAG2: 下載地址:https://www.apple.com/certificateauthority/AppleRootCA-G2.cer
rootCAG3: 下載地址:https://www.apple.com/certificateauthority/AppleRootCA-G3.cer

3、iOS客户端支付完成會校驗一次支付憑證,蘋果官方也會異步回調,我們需要做好分佈式鎖,客户端主動校驗或蘋果官方異步回調有一個成功即可
4、蘋果的異步回調會有很多類型,如ONE_TIME_CHARGE一次性購買、REFUND退款、SUBSCRIBED訂閲等,其中訂閲也分一次性訂閲,自動續訂等,需要根據自己的實際業務,再結合官方文檔,摸索清楚

Add a new Comments

Some HTML is okay.