跳到正文
返回博客
·约 2 分钟读完

验证 StoreKit 2 交易

VerificationResult、当前权益、Transaction.updates 监听、finish()、appAccountToken、退款撤销与环境,以及在服务器上校验带签名的 JWS。

StoreKit 2 把每笔交易都包在一个 VerificationResult 里。只根据 .verified 解锁内容,不要根据 .unverified;App 启动时就开始 Transaction.updates 循环,别漏掉在别处完成的购买;用 Transaction.currentEntitlements 了解用户现在拥有什么;只有在真正交付了用户付费的内容之后,才调用 finish()。Apple 说 StoreKit 会检查它交给你的值;想要更强的控制,可以用 jwsRepresentation 里的 JWS 字符串,在设备上或服务器上自己验证。在服务器上,Apple 推荐使用它的 App Store Server Library,它会替你检查签名、证书链、Bundle ID 和环境。处理一笔交易之前,还要看它的 revocationDate、appAccountToken 和 environment。

这篇讲验证。以服务器通知形式到达的退款,请看 App Store 退款与消费信息请求。调试时想看带签名的字符串,可以粘贴到 App Store JWS 解码器;本地测试购买,可以在 StoreKit 配置查看器 里打开你的 .storekit 文件。

设备上:VerificationResult

VerificationResult<SignedType> 是一个 frozen 枚举,有两个 case:

  • .verified(SignedType):值通过了 StoreKit 的检查。关联值是 App Store 签名的值。
  • .unverified(SignedType, VerificationError):值没通过检查。你仍能拿到签名的值,另外附带失败原因。

Apple 的 VerificationError 有这些 case:invalidCertificateChain(链中有证书过期或不受信任)、invalidDeviceVerification(签名的值不是为这台设备生成的)、invalidEncoding、invalidSignature(签名与头部和负载不匹配)、missingRequiredProperties,以及 revokedCertificate(Apple 说有时稍后重试就能解决)。

.unverified 怎么处理由你决定;Apple 的示例写的是“按你的商业模式处理未验证的交易”。对付费解锁来说,稳妥的做法是什么都不授予并记录错误。对未验证的值访问 payloadValue 会抛出错误,而 unsafePayloadValue 不管结果如何都会返回数据;Apple 说不要信任它,只用于调试。

交易的三个来源

  1. 购买调用。 product.purchase(options:) 返回 PurchaseResult:.success(带 VerificationResult<Transaction>)、.pending(需要用户操作,例如“请求购买”;完成后会通过 Transaction.updates 到达)或 .userCancelled。
  2. Transaction.currentEntitlements,即用户当前有权益的每个商品的最新交易。Apple 列出的包括:每个非消耗型内购;每个续期订阅中续期状态为 subscribed 或 inGracePeriod 的最新交易;以及每个非续期订阅的最新交易(含已完成的)。已退款或被撤销的商品不会出现,消耗型商品永远不会出现(那类请用 unfinished 或 all)。
  3. Transaction.updates,用于购买调用之外发生的一切:“请求购买”获批、优惠码兑换、在 App Store 里完成的购买,以及在另一台设备上完成的购买。Apple 说应在 App 启动时就创建一个 Task 来遍历它,因为未完成的交易只会在启动后立刻投递一次,“没有这个 Task 来监听,你的 App 可能会错过它们”。

finish()

finish() 告诉 App Store 你已交付内容或开通了服务。Apple 的说法是:在交付购买的内容或开通购买的服务之后调用;对于按需资源,要等下载完成。在你调用之前,交易一直留在 Transaction.unfinished 里。请在更新完自己的状态之后再调 finish(),不要在之前,这样中途崩溃也不会丢掉用户已付费的购买。

完整示例

下面这段代码用 iOS 18 SDK 和 Swift 6.4 编译通过(Swift 6 语言模式下也通过)。@MainActor 类在 init 里启动监听,购买时带上 appAccountToken,用权益重建已解锁集合,并且只对通过验证的交易调用 finish():

import Foundation
import StoreKit

@MainActor
final class Store {
    private(set) var unlocked: Set<String> = []
    private var updatesTask: Task<Void, Never>?

    // Start this as soon as the app launches (for example in init of your app model).
    init() {
        updatesTask = Task { [weak self] in
            for await result in Transaction.updates {
                await self?.handle(result)
            }
        }
    }

    deinit { updatesTask?.cancel() }

    func buy(_ product: Product, accountToken: UUID) async throws {
        let result = try await product.purchase(options: [.appAccountToken(accountToken)])
        switch result {
        case .success(let verification):
            await handle(verification)
        case .pending, .userCancelled:
            break
        @unknown default:
            break
        }
    }

    func refreshEntitlements() async {
        var ids: Set<String> = []
        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result, transaction.revocationDate == nil {
                ids.insert(transaction.productID)
            }
        }
        unlocked = ids
    }

    private func handle(_ result: VerificationResult<Transaction>) async {
        switch result {
        case .unverified(_, let error):
            // Do not unlock. Log the reason.
            print("unverified:", error)
        case .verified(let transaction):
            if transaction.revocationDate != nil {
                unlocked.remove(transaction.productID)
            } else {
                unlocked.insert(transaction.productID)
            }
            // Only after the content is delivered:
            await transaction.finish()
        }
    }
}

我们只做了类型检查,没有运行,因为购买需要 StoreKit 配置文件或沙盒账号。Apple 对 purchase(options:) 的说明建议:SwiftUI 用 PurchaseAction,UIKit 和 AppKit 用 purchase(confirmIn:options:);界面里请用它们,处理逻辑沿用上面的写法。

每笔交易要检查什么

字段Apple 的说明怎么做
revocationDateApp Store 退款或把交易从“家人共享”中撤销的日期。一旦有值,就收回权限。currentEntitlements 已经把这类交易排除在外。
appAccountToken你在购买选项里设置的 UUID,App Store 会在交易里原样返回。你没提供就不会有这个字段。在服务器上和当前登录用户比对,避免交易被挂到别人的账号上。
environment生成并签名这笔交易的服务器环境:设备上是 .production、.sandbox 或 .xcode;服务器数据里是 Production 或 Sandbox。让服务器拒绝与自己预期环境不一致的数据。
expirationDate 与 isUpgraded订阅到期或续期的日期;用户升级到另一个订阅时 isUpgraded 为 true。不要根据已过期或已升级的交易授予权限;Apple 自己的示例两种都会跳过。
productID你在 App Store Connect 里设定的商品标识。与你要解锁的内容对应起来。

如果要给在 App 之外完成的购买设置或修改 app account token,Apple 的服务器 API 有 Set App Account Token 接口。

服务器上:自己校验 JWS

带签名的字符串是 Compact Serialization 格式的 JWS:头部、负载、签名三段 Base64URL,用点连接。它与 App Store Server API 和 App Store Server Notifications V2 里的 JWSTransaction 格式相同;在通知里它位于 data 内的 signedTransactionInfo(续期信息是 signedRenewalInfo)。设备上的负载还有 deviceVerification 和 deviceVerificationNonce 字段,用来确认这个值属于该设备。

Apple 对头部的说明:alg 是签名算法,x5c 是证书链,顺序为:持有签名该 JWS 的密钥的证书、一张 Apple 中间证书(带有 Worldwide Developer Relations 扩展 ID 1.2.840.113635.100.6.2.1),以及一张 Apple 根证书。你信任的根证书必须是你自己从 Apple 下载的,而不是消息里带的那张:Apple PKI 网站列有 Apple Root CA - G3,我们的解码器就是用它来检查证书链的。我们的解码器还要求 alg 为 ES256。

这些检查你可以自己写,但 Apple 推荐使用它的开源库 App Store Server Library,有 Swift、Java、Python 和 Node 版本,提供 verifyAndDecodeTransaction、verifyAndDecodeAppTransaction 和 verifyAndDecodeRenewalInfo 函数。Swift 版里,SignedDataVerifier 需要 Apple 根证书、你的 Bundle ID、你的数字 App ID(生产环境必填)和环境;如果交易里的 Bundle ID 或环境对不上,它会拒绝。enableOnlineChecks 选项会开启吊销检查,并按当前日期检查是否过期。

下面这段代码基于 App Store Server Library 6.0.0 编译并运行过(它会拒绝一个乱写的字符串;没有真实的签名交易,我们只能测到这一步):

import Foundation
import AppStoreServerLibrary

func verifiedTransaction(
    signedTransaction: String,
    appleRootCertificates: [Data],
    bundleId: String,
    appAppleId: Int64?,
    environment: AppStoreEnvironment
) async throws -> JWSTransactionDecodedPayload? {
    let verifier = try SignedDataVerifier(
        rootCertificates: appleRootCertificates,
        bundleId: bundleId,
        appAppleId: appAppleId,
        environment: environment,
        enableOnlineChecks: true
    )
    switch await verifier.verifyAndDecodeTransaction(signedTransaction: signedTransaction) {
    case .valid(let payload):
        return payload
    case .invalid:
        return nil
    }
}

func shouldGrantAccess(_ t: JWSTransactionDecodedPayload, expectedUser: UUID) -> Bool {
    guard t.appAccountToken == expectedUser else { return false }
    return t.revocationDate == nil
}

在设备上,把 verification.jwsRepresentation 连同用户的会话一起发给服务器。在服务器上,先验证,再比对 appAccountToken 和用户,然后才执行操作。续期、退款这类长期状态,请依靠通知,不要只信 App 上报的内容。

来源

均于 2026 年 11 月 1 日查阅。

Apple 的页面和 API 会变化,请以上面各页的最新内容为准。

作者

独立开发者,在 Obelisk Club 做本地优先的 Mac 与 iOS 效率应用。 关于作者 →