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 排查顺序
- 确认请求使用的确实是目标
Product:
let products = try await Product.products(for: [requestedProductID])
guard let product = products.first,
product.id == requestedProductID else {
// 产品没有返回或返回值不符合预期
return
}
- 记录以下信息,不要只记录
productID:
requested product ID
fetched Product.id
Transaction.id
Transaction.originalID
Transaction.productID
purchaseDate
expirationDate
subscription status
autoRenewPreference
-
检查两个产品是否位于同一个 subscription group,并确认 subscription level 符合预期。
-
区分当前权益和下一周期产品。用 transaction 判断当前权益,用
autoRenewPreference判断续订切换。 -
确保每条已验证并已交付权益的 transaction 最终都调用
finish()。 -
不要让
Transaction.updates、currentEntitlements和本次购买结果覆盖同一个没有来源标记的状态变量。 -
使用新的 Sandbox tester,或在 App Store Connect 中清除测试账号的购买历史后重新测试。等待账号状态同步,再从无订阅状态开始完整测试一次。
-
分别测试立即升级、延迟降级、同级 crossgrade 和取消续订,不要将它们视为同一种切换行为。
何时应当认为是异常
如果同时满足以下条件:
- App Store Connect 明确将本次切换配置为立即生效的升级;
Product.id与用户选择完全一致;- transaction 已通过验证;
- 不存在并发状态覆盖或旧缓存;
- 使用全新的 Sandbox tester 仍能稳定复现;
- 订阅状态和
autoRenewPreference也无法解释为什么返回旧产品;
那么这个现象就不能简单归因于延迟降级。此时应保留完整日志、Sandbox tester 信息、transaction ID、发生时间和订阅组配置,并通过 Apple 的反馈渠道提交可复现案例。不要为了规避问题而强行将旧 transaction 改写成目标产品,否则可能错误授予用户尚未购买或尚未生效的权益。
备注:内容仅供参考。
