PHP解析苹果支付V2通知的三个核心步骤:下载公钥、验签与提取数据
PHP处理苹果 App Store Server Notifications V2 时,真正需要做的事情可以归纳成三步:准备并固定信任 Apple 的根证书,从通知中的 signedPayload 取得证书链并完成 JWS 验签,然后对内部的 signedTransactionInfo、signedRenewalInfo 再次验签并提取交易数据。不能把 V2 通知简单理解成“Base64 解码后读取 JSON”,因为解码只能看到内容,不能证明内容确实来自 Apple。
这里所说的“下载公钥”,更准确地说,是从 Apple PKI 下载并保存 Apple 根证书,把它作为服务器本地的可信根。每次通知真正用于验证 JWS 签名的叶子公钥,则来自 JWS Header 中的 x5c 证书链,而不是把某一把固定业务公钥写死在 PHP 代码里。
一、先理解V2通知到底是什么
App Store Server Notifications V2 发给服务器的 HTTP POST 请求体并不是 V1 那种可以直接读取业务字段的普通通知结构。最外层通常只有一个非常重要的字段:
{
"signedPayload": "一段JWS字符串"
}
signedPayload 使用 JWS Compact Serialization 格式,由三个部分组成:
Header.Payload.Signature
三个部分之间使用英文句点分隔。其中 Header 和 Payload 使用 Base64URL 编码,Signature 是数字签名。解开 Payload 可以看到 notificationType、notificationUUID、data 等信息,但在签名验证成功之前,这些内容都只能当作“不可信输入”。
更容易被忽略的是,外层 Payload 中的 data.signedTransactionInfo 和部分通知中的 data.signedRenewalInfo 本身又是独立 JWS。因此实际结构是“外层 JWS 包着内部 JWS”,不能只验一次签。
二、准备环境:PHP需要什么
下面示例以 PHP 8.1 及以上版本为基础,服务器需要启用 OpenSSL 扩展。可以先检查:
php -v
php -m | grep openssl
正常情况下应该能够看到 openssl。Web 环境还需要保证 PHP-FPM 使用的 PHP 配置同样加载了该扩展。
建议为 Apple 证书单独准备目录,例如:
project/
├── app/
│ └── AppleJwsVerifier.php
├── public/
│ └── apple-notify.php
└── storage/
└── apple-pki/
├── AppleIncRootCertificate.cer
├── AppleRootCA-G2.cer
└── AppleRootCA-G3.cer
文件名称只是本地管理方式,实际应从 Apple 官方 Apple PKI 页面中的 Apple Root Certificates 区域获取当前根证书。不要从论坛、网盘或其他项目仓库复制证书。
Apple 当前的服务器库也是让开发者把 Apple 根证书作为可信材料传入验证器。这样做的核心目的,是让“信任从服务器本地开始”,而不是相信请求自己带来的证书。
三、核心步骤一:下载Apple根证书,而不是固定叶子公钥
这里需要把一个容易混淆的概念讲清楚。JWS Header 中的 x5c 是一个 X.509 证书链。Apple 当前文档说明,这条链按照下面的顺序提供:
x5c[0]:包含实际 JWS 验签公钥的叶子证书。x5c[1]:Apple 中间证书。x5c[2]:Apple 根证书。
但不能因为 x5c[2] 写着“根证书”,就直接信任它。整个 Header 都来自网络请求,如果攻击者自己生成一套证书链并自己签名,单纯检查“链能不能自己闭合”没有意义。
正确方式是服务器提前从 Apple PKI 下载可信根证书。收到通知后,使用服务器本地的根证书验证中间证书,再由中间证书验证叶子证书,最后才从叶子证书取出公钥验证 JWS。
为什么不建议只保存一把叶子公钥
叶子签名证书可能发生轮换。如果 PHP 代码直接固定某一个叶子公钥,一旦 Apple 更换签名证书,原本合法的通知就可能突然无法验证。
固定可信根证书则更合理:具体叶子证书跟着每次 JWS 的 x5c 传递,只要它能够通过正确的 Apple 中间证书最终建立到本地可信 Apple Root CA 的证书链,就可以继续验证。
四、核心步骤二:PHP验证signedPayload的JWS签名
接下来进入核心代码。验证顺序不要颠倒:
- 把 JWS 分成 Header、Payload、Signature 三段。
- Base64URL 解码 Header。
- 确认
alg为预期的ES256。 - 读取
x5c的三张证书。 - 使用本地 Apple Root CA 验证证书链。
- 检查 Apple 用于 App Store 签名的证书扩展。
- 从叶子证书提取公钥。
- 使用叶子公钥验证 JWS Signature。
- 只有签名成功后,Payload 才能进入业务逻辑。
Apple 的官方服务器库还会处理证书撤销状态等检查。下面的 PHP 示例重点完整展示本地证书链、证书用途、有效期以及 JWS 签名验证这些核心环节,方便理解 PHP 端的工作原理。
AppleJwsVerifier.php
<?php
declare(strict_types=1);
final class AppleJwsVerifier
{
private array $trustedRoots = [];
private const APP_STORE_LEAF_OID = '1.2.840.113635.100.6.11.1';
private const WWDR_INTERMEDIATE_OID = '1.2.840.113635.100.6.2.1';
public function __construct(string $rootDirectory)
{
$files = array_merge(
glob(rtrim($rootDirectory, '/') . '/*.cer') ?: [],
glob(rtrim($rootDirectory, '/') . '/*.crt') ?: [],
glob(rtrim($rootDirectory, '/') . '/*.pem') ?: []
);
foreach ($files as $file) {
$raw = file_get_contents($file);
if ($raw === false) {
continue;
}
$pem = $this->certificateFileToPem($raw);
$cert = openssl_x509_read($pem);
if ($cert !== false) {
$this->trustedRoots[] = $cert;
}
}
if ($this->trustedRoots === []) {
throw new RuntimeException('没有加载到 Apple 根证书');
}
}
public function verifyAndDecode(string $jws): array
{
$parts = explode('.', $jws);
if (count($parts) !== 3) {
throw new RuntimeException('JWS 格式错误:必须包含三段');
}
[$encodedHeader, $encodedPayload, $encodedSignature] = $parts;
$headerJson = $this->base64UrlDecode($encodedHeader);
$payloadJson = $this->base64UrlDecode($encodedPayload);
$header = json_decode($headerJson, true, 512, JSON_THROW_ON_ERROR);
$payload = json_decode($payloadJson, true, 512, JSON_THROW_ON_ERROR);
if (($header['alg'] ?? null) !== 'ES256') {
throw new RuntimeException('不接受预期之外的 JWS 算法');
}
$x5c = $header['x5c'] ?? null;
if (!is_array($x5c) || count($x5c) !== 3) {
throw new RuntimeException('x5c 证书链必须包含三张证书');
}
$leaf = openssl_x509_read($this->x5cToPem($x5c[0]));
$intermediate = openssl_x509_read($this->x5cToPem($x5c[1]));
if ($leaf === false || $intermediate === false) {
throw new RuntimeException('无法解析 x5c 证书');
}
/*
* signedDate 在完成验签前不能作为业务数据使用,
* 这里只用于确定证书签名时点。
*/
$effectiveTime = time();
if (isset($payload['signedDate']) && is_numeric($payload['signedDate'])) {
$effectiveTime = intdiv((int) $payload['signedDate'], 1000);
}
$this->assertCertificateValidAt($leaf, $effectiveTime);
$this->assertCertificateValidAt($intermediate, $effectiveTime);
/*
* 叶子证书必须由中间证书签发。
*/
$intermediateKey = openssl_pkey_get_public($intermediate);
if ($intermediateKey === false ||
openssl_x509_verify($leaf, $intermediateKey) !== 1) {
throw new RuntimeException('叶子证书验证失败');
}
/*
* 中间证书必须能够建立到服务器本地可信 Apple Root CA。
* 注意:这里不把请求里的 x5c[2] 直接当作可信根。
*/
$trustedRoot = null;
foreach ($this->trustedRoots as $root) {
$rootKey = openssl_pkey_get_public($root);
if ($rootKey !== false &&
openssl_x509_verify($intermediate, $rootKey) === 1) {
$trustedRoot = $root;
break;
}
}
if ($trustedRoot === null) {
throw new RuntimeException('证书链不能建立到本地 Apple 根证书');
}
$this->assertCertificateValidAt($trustedRoot, $effectiveTime);
/*
* Apple 官方验证逻辑会检查证书用途扩展。
*/
$this->assertExtensionOid($leaf, self::APP_STORE_LEAF_OID);
$this->assertExtensionOid(
$intermediate,
self::WWDR_INTERMEDIATE_OID
);
$leafPublicKey = openssl_pkey_get_public($leaf);
if ($leafPublicKey === false) {
throw new RuntimeException('无法提取叶子证书公钥');
}
$rawSignature = $this->base64UrlDecode($encodedSignature);
/*
* JWS ES256 使用 JOSE 格式的 R || S,
* 而 openssl_verify() 需要 ASN.1 DER 格式。
*/
$derSignature = $this->joseEcdsaSignatureToDer($rawSignature);
$signingInput = $encodedHeader . '.' . $encodedPayload;
$verified = openssl_verify(
$signingInput,
$derSignature,
$leafPublicKey,
OPENSSL_ALGO_SHA256
);
if ($verified !== 1) {
throw new RuntimeException('JWS 签名验证失败');
}
return $payload;
}
private function base64UrlDecode(string $value): string
{
$padding = strlen($value) % 4;
if ($padding !== 0) {
$value .= str_repeat('=', 4 - $padding);
}
$decoded = base64_decode(
strtr($value, '-_', '+/'),
true
);
if ($decoded === false) {
throw new RuntimeException('Base64URL 解码失败');
}
return $decoded;
}
private function x5cToPem(string $certificate): string
{
$der = base64_decode($certificate, true);
if ($der === false) {
throw new RuntimeException('x5c Base64 解码失败');
}
return "-----BEGIN CERTIFICATE-----\n"
. chunk_split(base64_encode($der), 64, "\n")
. "-----END CERTIFICATE-----\n";
}
private function certificateFileToPem(string $raw): string
{
if (str_contains($raw, 'BEGIN CERTIFICATE')) {
return $raw;
}
return "-----BEGIN CERTIFICATE-----\n"
. chunk_split(base64_encode($raw), 64, "\n")
. "-----END CERTIFICATE-----\n";
}
private function assertCertificateValidAt(
mixed $certificate,
int $timestamp
): void {
$info = openssl_x509_parse($certificate, false);
if ($info === false) {
throw new RuntimeException('读取证书有效期失败');
}
$from = (int) ($info['validFrom_time_t'] ?? 0);
$to = (int) ($info['validTo_time_t'] ?? 0);
if ($timestamp < $from || $timestamp > $to) {
throw new RuntimeException('证书在 signedDate 时点不处于有效期');
}
}
private function assertExtensionOid(
mixed $certificate,
string $oid
): void {
$info = openssl_x509_parse($certificate, false);
if ($info === false) {
throw new RuntimeException('读取证书扩展失败');
}
$extensions = $info['extensions'] ?? [];
/*
* 不同 OpenSSL 构建对未知扩展的展示方式可能存在差异。
* 如果服务器不能暴露数字 OID,应改用能够完整读取
* X.509 扩展的库完成这一步,而不是直接跳过用途检查。
*/
if (!array_key_exists($oid, $extensions)) {
throw new RuntimeException(
'证书缺少 Apple 要求的扩展 OID:' . $oid
);
}
}
private function joseEcdsaSignatureToDer(string $signature): string
{
/*
* ES256 的 R 和 S 各占 32 字节。
*/
if (strlen($signature) !== 64) {
throw new RuntimeException('ES256 签名长度不正确');
}
$r = substr($signature, 0, 32);
$s = substr($signature, 32, 32);
$r = $this->asn1Integer($r);
$s = $this->asn1Integer($s);
$sequence = $r . $s;
return "\x30"
. $this->asn1Length(strlen($sequence))
. $sequence;
}
private function asn1Integer(string $value): string
{
$value = ltrim($value, "\x00");
if ($value === '') {
$value = "\x00";
}
if ((ord($value[0]) & 0x80) !== 0) {
$value = "\x00" . $value;
}
return "\x02"
. $this->asn1Length(strlen($value))
. $value;
}
private function asn1Length(int $length): string
{
if ($length < 128) {
return chr($length);
}
$bytes = '';
while ($length > 0) {
$bytes = chr($length & 0xFF) . $bytes;
$length >>= 8;
}
return chr(0x80 | strlen($bytes)) . $bytes;
}
}
R || S;PHP 的 openssl_verify() 需要 DER 编码的 ECDSA 签名。因此不能把第三段 Base64URL 解码后直接传给 openssl_verify(),需要先完成格式转换。
五、核心步骤三:提取交易数据时,内部JWS也必须验签
外层 signedPayload 验签成功之后,可以取得类似下面这些字段:
notificationType:通知类型。subtype:部分通知进一步细分的类型。notificationUUID:本次通知的唯一标识,可以用于幂等处理。signedDate:Apple 签署该通知的时间,单位为毫秒。data:常见内购通知的业务数据容器。
普通内购或订阅相关通知的 data 中还会出现:
data
├── bundleId
├── appAppleId
├── environment
├── status
├── signedTransactionInfo
└── signedRenewalInfo
其中 signedTransactionInfo 是交易 JWS,signedRenewalInfo 是续订信息 JWS。Apple 明确把这两类数据继续作为签名数据传递,所以它们需要使用同样的验证方法再次处理。
PHP继续验证交易信息
<?php
require __DIR__ . '/../app/AppleJwsVerifier.php';
$verifier = new AppleJwsVerifier(
__DIR__ . '/../storage/apple-pki'
);
/*
* $notification 是已经成功验签后的外层 Payload。
*/
$data = $notification['data'] ?? null;
if (is_array($data)) {
$transaction = null;
$renewal = null;
if (!empty($data['signedTransactionInfo'])) {
$transaction = $verifier->verifyAndDecode(
$data['signedTransactionInfo']
);
}
if (!empty($data['signedRenewalInfo'])) {
$renewal = $verifier->verifyAndDecode(
$data['signedRenewalInfo']
);
}
if ($transaction !== null) {
$transactionId = $transaction['transactionId'] ?? null;
$originalTransactionId =
$transaction['originalTransactionId'] ?? null;
$productId = $transaction['productId'] ?? null;
$purchaseDate = $transaction['purchaseDate'] ?? null;
$expiresDate = $transaction['expiresDate'] ?? null;
$revocationDate = $transaction['revocationDate'] ?? null;
$environment = $transaction['environment'] ?? null;
}
}
不要假设每一种通知都一定有 signedRenewalInfo。它主要与自动续期订阅相关,因此读取之前必须判断字段是否存在。
同样,不是所有 V2 通知都一定通过 data 返回。Apple 当前的 V2 Payload 还可能根据通知类型使用 summary、appData 或与外部购买相关的数据结构。所以代码应该根据 notificationType 和实际存在的对象分支处理,而不是无条件访问 $notification['data']。
六、完整的PHP通知入口示例
下面把接收请求、外层验签、应用身份检查、内部验签和幂等处理连接起来。示例假设回调文件位于 public/apple-notify.php。
<?php
declare(strict_types=1);
require __DIR__ . '/../app/AppleJwsVerifier.php';
const APPLE_BUNDLE_ID = 'com.example.myapp';
const APPLE_ENVIRONMENT = 'Production';
/*
* Production 环境建议同时校验 appAppleId。
* 示例值需要替换成 App Store Connect 中自己的应用 ID。
*/
const APPLE_APP_ID = 1234567890;
try {
$rawBody = file_get_contents('php://input');
if ($rawBody === false || $rawBody === '') {
throw new RuntimeException('请求体为空');
}
$request = json_decode(
$rawBody,
true,
512,
JSON_THROW_ON_ERROR
);
$signedPayload = $request['signedPayload'] ?? null;
if (!is_string($signedPayload) || $signedPayload === '') {
throw new RuntimeException('缺少 signedPayload');
}
$verifier = new AppleJwsVerifier(
__DIR__ . '/../storage/apple-pki'
);
/*
* 第一次验签:最外层通知。
*/
$notification = $verifier->verifyAndDecode($signedPayload);
$notificationType =
$notification['notificationType'] ?? null;
$notificationUuid =
$notification['notificationUUID'] ?? null;
$data = $notification['data'] ?? null;
/*
* 普通内购/订阅通知的应用身份校验。
*/
if (is_array($data)) {
$bundleId = $data['bundleId'] ?? null;
$environment = $data['environment'] ?? null;
$appAppleId = $data['appAppleId'] ?? null;
if ($bundleId !== APPLE_BUNDLE_ID) {
throw new RuntimeException('bundleId 不匹配');
}
if ($environment !== APPLE_ENVIRONMENT) {
throw new RuntimeException('environment 不匹配');
}
if (
APPLE_ENVIRONMENT === 'Production'
&& (int) $appAppleId !== APPLE_APP_ID
) {
throw new RuntimeException('appAppleId 不匹配');
}
}
/*
* notificationUUID 应建立唯一索引。
*
* 伪代码:
*
* if (notificationExists($notificationUuid)) {
* http_response_code(200);
* exit;
* }
*
* saveNotification($notificationUuid);
*/
$transaction = null;
$renewal = null;
if (
is_array($data)
&& !empty($data['signedTransactionInfo'])
) {
/*
* 第二次验签:交易 JWS。
*/
$transaction = $verifier->verifyAndDecode(
$data['signedTransactionInfo']
);
if (
isset($transaction['bundleId'])
&& $transaction['bundleId'] !== APPLE_BUNDLE_ID
) {
throw new RuntimeException(
'交易信息 bundleId 不匹配'
);
}
if (
isset($transaction['environment'])
&& $transaction['environment'] !== APPLE_ENVIRONMENT
) {
throw new RuntimeException(
'交易信息 environment 不匹配'
);
}
}
if (
is_array($data)
&& !empty($data['signedRenewalInfo'])
) {
/*
* 第三次验签:续订 JWS。
*/
$renewal = $verifier->verifyAndDecode(
$data['signedRenewalInfo']
);
if (
isset($renewal['environment'])
&& $renewal['environment'] !== APPLE_ENVIRONMENT
) {
throw new RuntimeException(
'续订信息 environment 不匹配'
);
}
}
/*
* 到这里才进入自己的订单业务。
*
* 例如:
*
* switch ($notificationType) {
* case 'DID_RENEW':
* // 根据 transaction 更新续费订单
* break;
*
* case 'REFUND':
* // 根据 transaction 更新退款状态
* break;
* }
*/
http_response_code(200);
exit;
} catch (JsonException $e) {
error_log(
'Apple V2 JSON error: ' . $e->getMessage()
);
http_response_code(400);
exit;
} catch (Throwable $e) {
error_log(
'Apple V2 notification error: '
. $e->getMessage()
);
http_response_code(400);
exit;
}
Production与Sandbox一定要区分
Apple 的环境字段不是随便的字符串,常见值为 Production 和 Sandbox。TestFlight 产生的交易也属于 Sandbox 环境。
因此,如果开发阶段一直使用 Sandbox,部署生产后却仍然把代码写成:
const APPLE_ENVIRONMENT = 'Sandbox';
即使证书链和签名全部正确,也应该因为环境不符合预期而拒绝进入正式订单逻辑。反过来也是一样。
七、哪些字段适合真正用于订单处理
实际业务通常不是把整个 Payload 保存下来就结束,而是需要从经过验证的交易信息里提取几个关键字段。
| 字段 | 用途 |
|---|---|
transactionId |
标识具体的一次交易,可用于防止同一交易被重复处理。 |
originalTransactionId |
对于自动续期订阅尤其重要,可用于关联同一订阅生命周期中的多次续费。 |
productId |
对应应用内商品或订阅产品。 |
purchaseDate |
购买或本次交易发生的时间。 |
expiresDate |
自动续期订阅常用,用于判断当前周期到期时间。 |
revocationDate |
存在时需要结合通知类型判断退款、撤销等情况。 |
environment |
区分 Production 与 Sandbox。 |
appAccountToken |
如果应用在购买时使用该机制,可以用于把 Apple 交易关联到自己的用户账户。 |
字段是否存在与商品类型、通知类型有关,不要把所有字段都写成数据库非空字段。例如非订阅商品通常没有可供业务使用的订阅到期时间。
八、notificationUUID一定要做幂等
Apple 通知不是“收到一次就绝不会再出现”。服务器没有正确响应、网络发生异常等情况下,生产环境可能出现重试。
因此推荐给 notificationUUID 建立数据库唯一索引,例如:
CREATE TABLE apple_notifications (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
notification_uuid VARCHAR(64) NOT NULL,
notification_type VARCHAR(64) DEFAULT NULL,
signed_date BIGINT DEFAULT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_notification_uuid (notification_uuid)
);
处理流程应该接近:
验签
↓
检查 notificationUUID
↓
已经处理 → 直接返回成功
↓
未处理
↓
在事务中记录 notificationUUID
↓
更新订单或订阅状态
↓
提交事务
↓
返回成功状态
对于同一笔交易,还可以根据实际业务给 transactionId 建立唯一约束。两层幂等可以避免通知重试造成重复发货、重复增加会员时间等问题。
Apple 当前文档说明,V2 生产通知发送失败后存在重试机制,因此服务端设计不能建立在“每个通知只发送一次”这个假设上。
九、最容易出现的几个错误
1. 直接Base64解码signedPayload就发货
这是最危险的做法。任何人都可以构造一段看起来像交易数据的 JSON,再编码成类似 JWS 的格式请求你的回调地址。只有签名验证成功,才能证明数据与对应签名证书之间存在可信关系。
2. 使用base64_decode直接解Payload
JWS 使用的是 Base64URL,字符表与普通 Base64 有区别:
Base64:
+ /
Base64URL:
- _
并且 Base64URL 经常省略末尾的 = padding,所以最好像前面的 base64UrlDecode() 一样统一转换和补齐。
3. 把x5c[2]直接当可信根
证书来自请求本身。真正的可信根必须来自服务器提前保存的 Apple PKI 根证书。否则“验证证书链”可能只是验证了攻击者自己构造的一条链。
4. 只验signedPayload,不验signedTransactionInfo
V2 的交易和续订数据同样是独立 JWS。最稳妥的原则很简单:看到 signed 开头、文档定义为 JWS 的数据,就按照独立签名数据重新验证。
5. 忽略bundleId、environment和appAppleId
签名正确只回答“这段数据经过了受信任的签名链”,业务上还需要回答“是不是我的应用、是不是当前环境的数据”。因此验签之后还要继续检查应用身份。
在正式环境中,应把自己的 bundleId 和 appAppleId 写入服务器配置,而不是相信回调数据告诉你的值。
6. 只检查证书能否连到Apple根,而不检查证书用途
Apple 会为不同用途签发不同证书。App Store 签名链中的叶子证书和中间证书具有特定扩展标识。Apple 自己的服务器库也会检查相关 OID,因此生产实现不应把“任意能够连到 Apple 根的证书”都当成 App Store Server Notification 签名证书。
十、四层检查通过后再修改订单
十一、如何验证自己的回调代码是否工作正常
代码部署以后,不建议直接等真实用户付款来测试。可以在 Sandbox 环境配置 App Store Server Notifications V2 地址,并使用 Apple 提供的测试通知机制检查服务器是否能够收到 V2 格式通知。
调试时建议至少记录这些信息:
- 请求到达时间。
notificationUUID。notificationType和subtype。environment。- 外层验签是否成功。
- 交易 JWS 是否成功。
- 续订 JWS 是否存在以及是否成功。
- 最终返回给 Apple 的 HTTP 状态码。
日志中没有必要长期完整输出 signedPayload、完整证书或所有交易数据。记录能够定位问题的字段即可,避免无意义地扩大敏感业务日志。
Apple 要求 V2 回调使用 HTTPS。服务器成功处理通知时返回 2xx 状态;发生处理失败时应根据具体情况返回失败状态,以便生产环境的通知重试机制发挥作用。
十二、最终应该记住的三个核心步骤
第一步:下载可信根证书。从 Apple PKI 获取 Apple Root Certificates,保存在服务器本地。所谓“下载公钥”不要理解成永久固定某一把叶子公钥。
第二步:完整验证JWS。解析 signedPayload 的 Header,读取 x5c,用本地 Apple 根证书建立证书链,验证证书用途和有效期,再使用叶子公钥验证 ES256 签名。
第三步:继续验签并提取业务数据。外层通过后,读取 notificationType、notificationUUID 和 data。如果存在 signedTransactionInfo 或 signedRenewalInfo,分别再次验签,然后才读取 transactionId、originalTransactionId、productId、expiresDate 等字段修改订单。
因此,PHP 解析苹果支付 V2 通知的重点并不在 JSON 解码本身,而在“信任链”。一条可靠的处理链应该是:
Apple 根证书
↓
验证 x5c 中间证书
↓
验证 x5c 叶子证书
↓
验证 signedPayload
↓
检查 bundleId / appAppleId / environment
↓
验证 signedTransactionInfo
↓
验证 signedRenewalInfo
↓
notificationUUID / transactionId 幂等
↓
更新订单和订阅状态
只要按照这个顺序实现,就能避免“能解码但没有验签”“只验外层不验交易”“错误信任请求自带根证书”以及“Sandbox 数据进入正式订单”等常见问题。
本文涉及的 V2 数据结构、x5c 证书链、Apple Root Certificates、环境字段及通知响应规则依据 Apple Developer Documentation 与 Apple PKI 当前公开说明整理。
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









