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

相关推荐
Michael_lcf19 小时前
Java的UDP通信:DatagramSocket和DatagramPacket
java·开发语言·udp
摇滚侠19 小时前
Spring Boot 3零基础教程,WEB 开发 HttpMessageConverter @ResponseBody 注解实现内容协商源码分析 笔记33
java·spring boot·笔记
计算机毕业设计小帅19 小时前
【2026计算机毕业设计】基于Springboot的校园电动车短租平台
spring boot·后端·课程设计
调试人生的显微镜19 小时前
Web前端开发工具实战指南 从开发到调试的完整提效方案
后端
静心观复19 小时前
drawio画java的uml的类图时,class和interface的区别是什么
java·uml·draw.io
Java水解19 小时前
【SQL】MySQL中空值处理COALESCE函数
后端·mysql
Laplaces Demon19 小时前
Spring 源码学习(十四)—— HandlerMethodArgumentResolver
java·开发语言·学习
guygg8819 小时前
Java 无锁方式实现高性能线程
java·开发语言
ss27319 小时前
手写Spring第7弹:Spring IoC容器深度解析:XML配置的完整指南
java·前端·数据库
Python私教19 小时前
DRF:Django REST Framework框架介绍
后端·python·django