PAYATHON 2026

Flutter iOS 订阅重新购买后为何返回 restored 和已过期交易?

排障赵师傅

结论

PurchaseStatus.restored 不能用来判断订阅是否有效;一笔交易的 expiresDate 已经过期,也不能直接说明用户当前没有订阅。

客户端和后端都需要调整:

  • 客户端逐笔处理 purchased、restored 事件,移除会丢弃事件的防抖逻辑。
  • 将 serverVerificationData 原样提交后端,按实际数据格式验证。
  • 后端根据 Apple 的订阅状态和交易记录计算当前权益,避免一笔旧交易覆盖已有的有效订阅。
  • 后端完成验证并持久化处理结果后,再调用 completePurchase()。

目前缺少 in_app_purchase、in_app_purchase_storekit 的版本信息,也不清楚实际使用的是 StoreKit 1 还是 StoreKit 2。仅凭 restored,无法确定这次回调的具体来源。

为什么重新购买后会收到 restored?

PurchaseStatus 是 Flutter 插件提供的事件状态,描述客户端收到的购买事件,不能代替订阅的业务状态。

恢复购买会返回 restored。历史交易和未完成交易如何重新投递、StoreKit 事件如何映射到 Flutter 状态,则取决于插件版本和所用的 StoreKit 路径。在购买操作前后收到一条 restored,并不代表它就是刚产生的新交易。

排查时应同时记录:

插件版本
StoreKit 1 / StoreKit 2
PurchaseStatus
productID
transactionId
originalTransactionId
purchaseDate
expiresDate
environment
pendingCompletePurchase

服务端日志中的交易字段应取自验证后的数据。

字段含义与处理方式
transactionId标识具体交易,续订会产生新的交易,可用于交易幂等处理
originalTransactionId用于关联同一订阅交易链,不适合用来对每次续订去重
transactionReason = "RENEWAL"表示这是一笔续订交易,不能据此判断它当前仍然有效
expiresDate这笔交易对应订阅周期的到期时间,不会随着后续续订自动更新

同一交易链中的 originalTransactionId 保持不变是正常现象。不能因为它没有变化,就把新的续订交易当成重复交易丢弃。用户以后重新订阅时,也未必都属于同一条交易链。

当前代码为什么容易选中旧交易?

.last 只代表列表位置

这段代码没有按购买时间或到期时间排序:

final latest = purchases
    .where((p) =>
        p.status == PurchaseStatus.purchased ||
        p.status == PurchaseStatus.restored)
    .toList()
    .last;

它取到的是过滤后列表的最后一项,不能据此确定“最新有效订阅”。一个事件批次也未必包含完整的交易历史。

如果批次中只有 pending、error 或 canceled,过滤后的列表会为空,.last 将抛出异常,后面的 switch 也就无法执行。

防抖会直接丢掉交易批次

每次收到事件时,代码都会执行:

_debounceTimer?.cancel();

如果第二批事件在 500 毫秒内到达,第一批事件尚未执行的处理任务就会被取消。这样只保留了最后一批数据,前面的交易并没有合并进来。

购买事件应进入队列逐笔处理。如果需要减少界面刷新,可以合并刷新操作,但必须保留全部交易处理任务。

去重标记写得太早

代码在后端验证前就执行:

_processedPurchaseIds.add(transactionId);
_processedTransactions.add(transactionId);

一旦请求超时或验证服务暂时不可用,该交易再次到达时就会被跳过。仅在后端返回 expired 时清空集合,无法解决网络失败后的重试问题。

交易幂等应由后端数据库保证,例如使用 (environment, transactionId) 唯一键。客户端可以防止同一交易被同时处理,但“已尝试发送”和“已成功处理”需要分开记录。

完成交易的时机过早

当前调用顺序是:

await _iap.completePurchase(purchase);
await repository.postSubscriptionApi(...);

后端尚未确认并保存结果,客户端就已经完成交易。如果随后的请求失败,恢复处理会更困难。

正常流程应先验证、保存交易并处理权益,再完成交易。历史交易即使已经过期,只要验证成功且业务处理完成,也应根据 pendingCompletePurchase 完成交易,无需等它变成有效订阅。

serverVerificationData 应该怎样验证?

先确认数据格式。serverVerificationData 是提交给服务端的验证数据,这个字段名并不意味着内容一定是 JWT。

StoreKit 2 的 JWS 交易数据

如果当前插件和 StoreKit 路径提供的是 Apple 签名交易 JWS,后端需要:

  1. 使用受信任的 Apple 根证书,验证签名和证书链。
  2. 校验 bundleId、环境以及所用验证库要求的应用标识。
  3. 校验商品是否属于本应用允许的订阅商品。
  4. 校验交易与当前业务账号的绑定关系。
  5. 保存验证后的交易,再查询、计算当前订阅权益。

可以使用 Apple App Store Server Library 中适合后端语言的验证功能。

执行以下代码只会读取 payload:

final decoded = decodeJwt(token);

它不能证明签名有效,也不能证明这笔交易属于当前应用或当前用户。

JWS 描述的是一笔交易。即使后来发生了新的续订,旧 JWS 的内容也不会变化,其中已经过期的 expiresDate 仍会保持原值。

StoreKit 1 的 App Receipt

如果拿到的是 Base64 编码的 App Receipt,应使用收据验证流程,不能按 JWT 解码。

维护旧收据接口时,需要区分生产环境与 Sandbox,并处理 Apple 返回的环境不匹配结果。新建服务端订阅体系时,可采用 App Store Server API 和 App Store Server Notifications V2。

客户端只负责传输验证材料。iOS 收据、iOS JWS 和 Android purchase token 应分别进入对应的验证流程。

后端如何确定最新且有效的订阅?

后端收到历史交易后,应将它作为查询订阅的线索,避免直接把账户状态改成这笔交易的状态。

处理流程可以分为以下几步:

  1. 验证客户端提交的数据,从可信结果中取得交易标识和环境。
  2. 使用适用的交易标识调用 App Store Server API 的 Get All Subscription Statuses,读取相关订阅组的状态。
  3. 需要补齐交易历史时,调用 Get Transaction History,处理分页,并验证返回的签名数据。
  4. 在对应用户、订阅组和交易链范围内计算权益,再持久化结果。

如果业务同时提供月订阅和年订阅,还需要处理同一订阅组内的升级、降级及商品替换,不能只筛选客户端当前选中的 productID。

“最新交易”和“有效权益”需要分开判断:

当前状态权益处理
有效订阅根据有效期授予权益
Billing Grace Period根据经验证的宽限期信息保留相应权益
Billing Retry,且没有有效宽限期不能仅凭正在重试扣款就延长权益
已过期当前没有其他有效订阅时,结束权益
已撤销或发生影响权益的退款按撤销信息重新计算权益

只选取最大的 expiresDate 还不够,还要考虑撤销、升级替换、宽限期和订阅组关系。比较时间时应使用服务端时钟。JWS 中的日期字段通常是 Unix 毫秒时间戳,注意不要与秒混用。

例如,后端已经确认用户拥有一个有效的新周期,随后客户端又恢复出上一个已过期周期。后端可以保存这笔历史交易,但不能用它把用户状态覆盖成 expired。

数据库应分别保存交易记录和当前权益,用唯一键防止交易重复入账,同时允许重复请求触发状态同步。App Store Server Notifications V2 用于在客户端未运行时接收续订、到期、退款等变化。通知同样需要验签和幂等处理,并防止乱序事件覆盖较新的状态。

Flutter 事件处理示例

iOS 购买事件可以按下面的方式处理:沿用现有仓储方法,将防抖和 .last 取值改为串行处理每个批次,逐笔提交验证,并在后端成功处理后完成交易。

这里要求 postSubscriptionApi() 仅在后端完成验证、持久化交易并处理当前权益后返回成功。历史交易验证成功但已经过期,也属于可以正常返回的业务结果。

Future<void> _purchaseQueue = Future<void>.value();

void _onPurchaseUpdated(List<PurchaseDetails> purchases) {
  final batch = List<PurchaseDetails>.of(purchases);

  _purchaseQueue = _purchaseQueue
      .then((_) => _processIOSPurchases(batch))
      .catchError((Object error, StackTrace stackTrace) {
    if (!isClosed) {
      emit(state.copyWith(
        loading: false,
        error: 'Purchase processing failed: $error',
      ));
    }
  });
}

Future<void> _processIOSPurchases(
  List<PurchaseDetails> purchases,
) async {
  for (final purchase in purchases) {
    try {
      switch (purchase.status) {
        case PurchaseStatus.pending:
          if (!isClosed) {
            emit(state.copyWith(loading: true));
          }
          break;

        case PurchaseStatus.purchased:
        case PurchaseStatus.restored:
          await _verifyAndFinishIOSPurchase(purchase);
          break;

        case PurchaseStatus.error:
          if (!isClosed) {
            emit(state.copyWith(
              loading: false,
              error: purchase.error?.message ?? 'Purchase failed',
            ));
          }
          break;

        case PurchaseStatus.canceled:
          if (!isClosed) {
            emit(state.copyWith(
              loading: false,
              error: 'Purchase cancelled',
            ));
          }
          break;
      }
    } catch (error) {
      // 保留重试能力,不把交易提前标记为已成功处理。
      if (!isClosed) {
        emit(state.copyWith(
          loading: false,
          error: 'Subscription sync failed: $error',
        ));
      }
    }
  }
}

Future<void> _verifyAndFinishIOSPurchase(
  PurchaseDetails purchase,
) async {
  final verificationData =
      purchase.verificationData.serverVerificationData;

  if (verificationData.isEmpty) {
    throw StateError('Missing server verification data');
  }

  await AppleSubscriptionRepository().postSubscriptionApi(
    token: verificationData,
    selectedSubscriptionId: purchase.productID,
    platform: 'ios',
    packageName: '',
  );

  if (purchase.pendingCompletePurchase) {
    await _iap.completePurchase(purchase);
  }

  if (!isClosed) {
    await getSubscriptionsFromBackendServer();
  }
}

这段代码不依赖客户端解码,也不使用永久去重集合。后端仍须校验提交的商品信息,不能因为参数名是 selectedSubscriptionId 就直接信任它。

示例尚未实现持久化重试。正式实现时,应保存因网络失败而未完成的待验证任务,并在网络恢复或重新进入应用时重试。服务暂时不可用时,应显示“同步失败”或“验证中”,不能直接显示“订阅已过期”。对于已经确定无效的交易,还需要明确的拒绝和队列清理流程,避免无限重试。

Sandbox 和取消订阅还要注意什么?

Sandbox 的订阅周期会加速,具体时长受测试设置影响。“约 5 分钟”只是当前的测试条件,不能当成所有订阅的固定规则。

purchaseDate 和 expiresDate 很接近并不一定异常。排查时,需要确认回调对应哪笔交易,以及验证时是否已经超过到期时间。仅看两个日期相差多少,无法判断是否有问题。

恢复购买不会延长订阅。固定等待 8 秒只能用于界面超时处理,不能证明所有恢复交易都已到达并完成验证。

当前 cancelSubscription() 只会清空本地状态并显示通知,并未取消 Apple 的自动续订。应引导用户进入订阅管理页面,再由后端同步 Apple 状态。用户关闭自动续订后,通常仍可使用到当前付费周期结束,此时不能立即清空权益。

账号关联应使用稳定的内部用户标识,避免使用可能重名或被修改的昵称。如果采用 appAccountToken,应通过实际插件支持的 API 传入服务端分配的 UUID,并在后端验证绑定关系。

备注:内容仅供参考。