苹果内购 V1 与 V2 支付流程对比(附示例java代码)

苹果内购 V1 与 V2 支付流程对比(附示例java代码)

本教程的代码示例依赖苹果java sdk,并要求java 11+,java 8教程整理中

官方文档:developer.apple.com/documentati...

国内大部分开发者对 微信支付、支付宝支付 的流程都比较熟悉,其典型的三步为:

  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可自己下载,其余参数登录苹果控制台获取即可)

makefile 复制代码
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

引入依赖(查看git地址,获取最新版本)

xml 复制代码
<dependency>
    <groupId>com.apple.itunes.storekit</groupId>
    <artifactId>app-store-server-library</artifactId>
    <version>3.5.0</version>
</dependency>

git地址:github.com/apple/app-s...

注意:sdk的全部版本需要Java 11+

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

1、ios客户端调用预下单接口获取UUID后,会调用苹果内购的sdk进行支付,用户支付完成时,苹果会回调给客户端支付凭证,其中有个transactionId字段,是苹果的内购单号,需要传给服务端

2、服务端拿到transactionId后,开始校验

java 复制代码
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参数,该参数的值是一长串加密后的支付凭证

json 复制代码
{
   "signedPayload": ""
}

获取到参数后,直接解密

ini 复制代码
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、在苹果控制台以及相应网站复制对应参数和下载对应文件

makefile 复制代码
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订阅等,其中订阅也分一次性订阅,自动续订等,需要根据自己的实际业务,再结合官方文档,摸索清楚

相关推荐
叫我阿柒啊5 小时前
从全栈工程师视角解析Java与前端技术在电商场景中的应用
java· 消息队列· spring boot· 微服务· vue3· 安全· 前端框架
华仔啊5 小时前
Redis 不只是缓存!Java 打工人必知的 10 个真实工作场景,第 5 个太香了
java·后端
程序边界5 小时前
Oracle到金仓数据库信创改造迁移实施规划方案(上篇)
后端
|CXHAO|5 小时前
使用tomcat本地部署draw.io
java·tomcat·draw.io
韦德说5 小时前
我的副业之 - 三年磨一剑,让非技术人员也能实现建站自由
后端·程序员·开源
祈祷苍天赐我java之术5 小时前
Maven 从入门到精通
java·maven
绝无仅有5 小时前
某大厂MySQL面试之SQL注入触点发现与SQLMap测试
后端·面试·github
没有bug.的程序员5 小时前
Redis 内存管理机制:深度解析与性能优化实践
java·数据库·redis·性能优化·内存管理机制
绝无仅有5 小时前
某互联网大厂的面试go语言从基础到实战的经验和总结
后端·面试·github