PAYATHON 2026

有效 receipt 中的 iOS purchase transaction 未出现在 payment queue

支付老李

结论

Receipt 和 SKPaymentQueue 不是同一份交易列表。

Receipt 中有某笔 purchase,只代表 Apple 已将它写入购买凭证,不代表对应的 SKPaymentTransaction 仍未完成,也不意味着它会再次出现在 payment queue 中。调用 finishTransaction: 会从队列中移除交易,但通常不会删除 receipt 中的购买记录。

因此,如果旧交易存在于 receipt 中,却不在 SKPaymentQueue 中:

  • 不要尝试根据 transaction identifier 构造 transaction,或对其调用 finish。
  • 根据验证后的 receipt 恢复用户权益。
  • 仅对 queue 回调中实际收到的 transaction 调用 finishTransaction:。
  • 如需重新获取可恢复的购买,可使用 StoreKit 的恢复购买流程。该流程不适用于消耗型商品,也不保证将 receipt 中的每条历史记录重新放入队列。

为什么 receipt 中仍然有这些交易

调用 finishTransaction: 是在通知 StoreKit,应用已经完成这笔交易的处理,可以将其从 payment queue 中移除。这个接口不会删除购买历史。

所以下列情况可以同时存在:

  1. Receipt 中包含某笔历史购买。
  2. 该交易以前已经被 finish。
  3. 当前 SKPaymentQueue 中没有对应的 SKPaymentTransaction。

自动续期订阅尤其如此。Receipt 可能包含多次续期记录,这些记录用于判断订阅状态和有效期,并不表示每条记录都要作为待处理 transaction 再次进入 queue。

Sandbox 环境还可能积累大量测试购买记录。只看 receipt 中是否有历史记录,无法判断当时是否确实漏掉了 finishTransaction:。

正确的处理方式

1. 启动时尽早注册 observer

在应用启动阶段注册 observer,并让它保持足够长的生命周期:

[[SKPaymentQueue defaultQueue] addTransactionObserver:self];

StoreKit 只会重新投递仍由队列管理的未完成交易。已经离开队列的历史交易,不会因为注册 observer 而重新出现。

2. 处理实际收到的 transaction

购买成功后,先完成服务端验证和权益发放,再调用 finishTransaction::

- (void)paymentQueue:(SKPaymentQueue *)queue
 updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions
{
    for (SKPaymentTransaction *transaction in transactions) {
        switch (transaction.transactionState) {
            case SKPaymentTransactionStatePurchasing:
                break;

            case SKPaymentTransactionStatePurchased:
                [self validateAndDeliverTransaction:transaction
                                        completion:^(BOOL success) {
                    if (success) {
                        [[SKPaymentQueue defaultQueue]
                            finishTransaction:transaction];
                    }
                }];
                break;

            case SKPaymentTransactionStateRestored:
                [self restoreEntitlementForTransaction:transaction
                                            completion:^(BOOL success) {
                    if (success) {
                        [[SKPaymentQueue defaultQueue]
                            finishTransaction:transaction];
                    }
                }];
                break;

            case SKPaymentTransactionStateFailed:
                [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
                break;

            case SKPaymentTransactionStateDeferred:
                break;
        }
    }
}

成功交易的权益尚未可靠保存时,不要调用 finish。否则,应用崩溃或网络请求失败后,可能无法再次处理该 transaction。

3. 使用 receipt 恢复权益

如果服务端验证发现 receipt 中有旧购买,但本地或服务端尚未记录相应权益,应直接进行对账,不要等待 queue 返回历史 transaction。

常见流程如下:

  1. 验证 receipt 的真实性。
  2. 读取与商品类型相关的购买记录。
  3. 使用 transaction_id、original_transaction_id 等字段进行幂等去重。
  4. 根据商品类型、取消状态和有效期计算当前权益。
  5. 将结果写入服务端账户。

处理自动续期订阅时,通常应围绕 original_transaction_id 建立订阅链,再根据有效期和相关状态判断当前权益。不要将 receipt 中的每条记录都视为需要再次交付的独立队列事件。

4. 必要时发起恢复购买

对于非消耗型商品和支持恢复的订阅,可以让用户触发:

[[SKPaymentQueue defaultQueue] restoreCompletedTransactions];

恢复的交易会以 SKPaymentTransactionStateRestored 状态返回。权益恢复完成后,仍要调用 finishTransaction:。

需要注意:

  • 消耗型商品不能通过 restoreCompletedTransactions 恢复。
  • 恢复购买不会把 receipt 中的所有历史记录重新加入 queue。
  • 如果有效 receipt 已经足以确定权益,通常可以直接通过服务端对账完成恢复。

如果只需要更新本地 receipt,可以使用 SKReceiptRefreshRequest。刷新 receipt 同样不会让历史交易重新进入 payment queue。

无法直接执行的操作

下面的遍历只能处理当前仍在 queue 中的交易:

for (SKPaymentTransaction *transaction
         in [[SKPaymentQueue defaultQueue] transactions]) {
    [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}

它无法处理 receipt 中的历史条目。公开 API 也不支持根据 receipt 里的 transaction identifier 创建 SKPaymentTransaction,再将其传给 finishTransaction:。应用不应保存旧的 transaction 对象,以后再用它伪造完成流程。

实现时的注意事项

  • Receipt 是判断购买和权益的数据来源之一,payment queue 则是待处理事件队列,两者的条目数量不能直接对应。
  • 服务端发放权益时必须保证幂等,避免重复处理同一个 transaction_id。
  • 应分别为订阅、非消耗型商品和消耗型商品设计恢复策略。
  • SKPaymentTransactionStatePurchasing 表示交易仍在进行,不能发放权益,也不能 finish。
  • SKPaymentTransactionStateDeferred 可能还在等待批准,不能按成功或失败处理。
  • 删除应用、切换 sandbox tester、更换设备或刷新 receipt,都可能让测试现象变得更复杂。

如果能确认某笔交易从未调用过 finishTransaction:,但 StoreKit 长期不再投递它,可以先换用新的 sandbox tester 或重置测试账户环境进行复现。同时记录商品 ID、transaction identifier、时间、设备系统版本和完整的 StoreKit 日志,再向 Apple Developer Technical Support 提交问题。业务处理仍应通过 receipt 对账恢复权益,不能依赖历史 transaction 一定会重新进入 queue。

备注:内容仅供参考。