PAYATHON 2026

StoreKit 2 Sandbox 订阅返回错误 productID

数据阿明

结论

Product.PurchaseResult 中的 Transaction.productID 表示这笔交易当前授予的有效权益,不一定是用户刚刚选择的下一周期订阅产品。

自动续期订阅切换可能不会立刻生效:

  • 升级通常立即生效,并生成新产品的 transaction。
  • 降级通常到下一个续订周期才生效。在此之前,当前有效 transaction 仍属于原订阅。
  • 同级产品之间的 crossgrade 是否立即生效,取决于订阅时长等配置。
  • Sandbox 会加快续订周期。反复使用同一个测试账号时,历史交易、待生效的续订变更和未完成交易可能叠加,让问题更难判断。

所以,不能只根据 purchase() 返回的 transaction 中的 productID,判断升级或降级是否已按预期安排。当前权益应以 transaction 为准,下一周期续订哪个产品则要查看订阅状态中的 RenewalInfo.autoRenewPreference。

如果按照 App Store Connect 的订阅级别配置,本次操作应该是立即生效的升级,但返回的仍是旧 productID,还需要检查订阅分组、交易处理代码和 Sandbox 账号状态。

为什么会返回原来的 productID

降级尚未生效

用户从高等级订阅切换到低等级订阅后,通常仍能使用已经付费的高等级权益,直到当前周期结束。此时:

  • Transaction.productID 仍是原产品;
  • 新产品可能已经写入续订偏好;
  • 到续订时间后,StoreKit 才会生成新产品对应的 transaction。

这不代表用户购买了错误的产品。当前权益和未来的续订计划是两种不同的状态。

App Store Connect 中的订阅级别不符合预期

StoreKit 会根据同一 subscription group 中的等级判断一次切换属于 upgrade、downgrade 还是 crossgrade,不会根据价格或产品名称判断。

需要检查:

  • 两个产品是否属于同一个 subscription group;
  • 产品的 subscription level 是否正确;
  • 预期的高等级产品是否配置在更高优先级;
  • crossgrade 产品的订阅时长是否一致。

如果级别配置反了,开发者认为是升级的操作可能会被 App Store 视为降级,并推迟到下一个续订周期生效。

Sandbox 账号保留了已有状态

同一个 Sandbox tester 多次购买、升级、降级和取消后,账号中可能同时存在:

  • 当前有效订阅;
  • 已安排但尚未生效的降级;
  • 加速续订产生的历史 transaction;
  • billing retry、grace period 或已过期状态;
  • 上一次测试留下的续订偏好。

调试单一流程时,最好使用新建的 Sandbox tester,或先清除测试账号的购买历史。还要确认设备当前登录的是预期的 Sandbox 账号。

应用错误地使用了旧交易

还要检查应用是否误把其他交易路径得到的 transaction 当成本次 purchase() 的返回值。例如:

  • Transaction.updates 和 purchase() 同时更新同一个共享变量;
  • UI 展示的是缓存中的 transaction;
  • 遍历 Transaction.currentEntitlements 时直接取了第一条,没有按照 subscription group 或产品过滤;
  • 没有正确处理 VerificationResult;
  • transaction 处理完成后没有调用 finish()。

应分别记录 Transaction.updates、currentEntitlements 和 purchase() 返回值的来源,避免并发处理时相互覆盖。

正确处理 purchase() 的结果

先验证交易,再将它作为当前权益处理。不要预先假定它一定等于用户点击的产品:

import StoreKit

enum StoreError: Error {
    case failedVerification
    case unexpectedProduct(expected: String, actual: String)
}

func checkVerified<T>(
    _ result: VerificationResult<T>
) throws -> T {
    switch result {
    case .verified(let value):
        return value
    case .unverified:
        throw StoreError.failedVerification
    }
}

func purchase(_ product: Product) async throws {
    let requestedProductID = product.id
    let result = try await product.purchase()

    switch result {
    case .success(let verificationResult):
        let transaction = try checkVerified(verificationResult)

        print("Requested product:", requestedProductID)
        print("Transaction product:", transaction.productID)
        print("Transaction ID:", transaction.id)

        // 使用 transaction.productID 更新当前已经生效的权益。
        await updateCurrentEntitlement(from: transaction)

        // 在权益已经持久化或交付后完成交易。
        await transaction.finish()

    case .pending:
        // 交易尚未完成,不能授予新权益。
        print("Purchase is pending")

    case .userCancelled:
        print("Purchase was cancelled")

    @unknown default:
        break
    }
}

requestedProductID 可用于日志记录和诊断,但授予权益时必须以验证通过的 transaction 为准。不能因为用户点击了某个产品,就提前将本地会员等级改成该产品。

检查下一周期的续订产品

对于自动续期订阅,还要读取 Product.SubscriptionInfo.Status:

  • status.transaction 描述当前订阅的 transaction;
  • status.renewalInfo.autoRenewPreference 表示下次自动续订倾向的产品;
  • 两者不同时,通常说明产品切换已经安排,但尚未生效。
import StoreKit

func inspectSubscriptionStatus(for product: Product) async throws {
    guard let subscription = product.subscription else {
        return
    }

    let statuses = try await subscription.status

    for status in statuses {
        let transaction = try checkVerified(status.transaction)
        let renewalInfo = try checkVerified(status.renewalInfo)

        print("Current product:", transaction.productID)
        print(
            "Next renewal product:",
            renewalInfo.autoRenewPreference ?? transaction.productID
        )
        print("Subscription state:", status.state)
    }
}

例如,日志显示:

Current product: premium_monthly
Next renewal product: basic_monthly

这通常表示 premium_monthly 仍是当前有效权益,basic_monthly 已安排在下次续订时生效。应用此时应继续提供 Premium 权益,不能提前降低用户等级。

autoRenewPreference 为空时,不能自行推断一定存在产品切换。还需要结合 status.state、到期时间和服务端的 App Store Server API 数据判断。

监听真正生效的新交易

延迟生效的降级会在后续续订时生成新 transaction。应用运行期间应持续监听 Transaction.updates:

import StoreKit

func observeTransactions() -> Task<Void, Never> {
    Task.detached {
        for await result in Transaction.updates {
            do {
                let transaction = try checkVerified(result)

                await updateCurrentEntitlement(from: transaction)
                await transaction.finish()
            } catch {
                print("Transaction verification failed:", error)
            }
        }
    }
}

应用启动、恢复登录或进入订阅页面时,还应通过 Transaction.currentEntitlements 重建当前权益,不能只依赖某次 purchase() 的即时返回值:

func refreshCurrentEntitlements() async {
    for await result in Transaction.currentEntitlements {
        guard case .verified(let transaction) = result else {
            continue
        }

        await updateCurrentEntitlement(from: transaction)
    }
}

如果有服务端,生产环境更适合结合 App Store Server Notifications 和 App Store Server API 维护订阅状态。应用退出、网络中断或跨设备购买都可能导致客户端回调延迟。

建议的 Sandbox 排查顺序

  1. 确认请求使用的确实是目标 Product:
let products = try await Product.products(for: [requestedProductID])

guard let product = products.first,
      product.id == requestedProductID else {
    // 产品没有返回或返回值不符合预期
    return
}
  1. 记录以下信息,不要只记录 productID:
requested product ID
fetched Product.id
Transaction.id
Transaction.originalID
Transaction.productID
purchaseDate
expirationDate
subscription status
autoRenewPreference
  1. 检查两个产品是否位于同一个 subscription group,并确认 subscription level 符合预期。

  2. 区分当前权益和下一周期产品。用 transaction 判断当前权益,用 autoRenewPreference 判断续订切换。

  3. 确保每条已验证并已交付权益的 transaction 最终都调用 finish()。

  4. 不要让 Transaction.updates、currentEntitlements 和本次购买结果覆盖同一个没有来源标记的状态变量。

  5. 使用新的 Sandbox tester,或在 App Store Connect 中清除测试账号的购买历史后重新测试。等待账号状态同步,再从无订阅状态开始完整测试一次。

  6. 分别测试立即升级、延迟降级、同级 crossgrade 和取消续订,不要将它们视为同一种切换行为。

何时应当认为是异常

如果同时满足以下条件:

  • App Store Connect 明确将本次切换配置为立即生效的升级;
  • Product.id 与用户选择完全一致;
  • transaction 已通过验证;
  • 不存在并发状态覆盖或旧缓存;
  • 使用全新的 Sandbox tester 仍能稳定复现;
  • 订阅状态和 autoRenewPreference 也无法解释为什么返回旧产品;

那么这个现象就不能简单归因于延迟降级。此时应保留完整日志、Sandbox tester 信息、transaction ID、发生时间和订阅组配置,并通过 Apple 的反馈渠道提交可复现案例。不要为了规避问题而强行将旧 transaction 改写成目标产品,否则可能错误授予用户尚未购买或尚未生效的权益。

备注:内容仅供参考。