Flutter订阅场景in_app_purchase遇Pending Transaction异常及回调重复执行问题
问题根因
storekit_duplicate_product_object异常:iOS StoreKit队列中存在未调用completePurchase收尾的挂起交易,重复发起同商品购买时就会触发该报错,本质是历史交易未做强制收尾逻辑updateUserSubscription多次触发:未对交易做去重标记,且将历史拉取的旧购买记录、Stream推送的全量交易都纳入了后端上报流程,没有区分“用户当前主动发起的购买”和“系统恢复的历史交易”
代码修改方案
1. 新增类成员状态标记
在页面类的成员变量区新增以下状态位,用于去重、状态追踪:
// 已处理过的交易ID集合,避免重复走逻辑 final Set<String> _processedPurchaseIds = {}; // 记录用户当前主动发起购买的商品ID,仅对该ID对应的交易做后端上报、跳会员页逻辑 String? _currentSelectedProductId; // 标记后端上报是否进行中,避免并发重复请求 bool _isUploadingReceipt = false;
2. 购买发起逻辑调整
在SUBSCRIBE按钮的确认购买回调里,调用平台购买方法前,先记录当前用户选中的商品ID:
buttonClick: () { // 记录当前用户主动选择的购买商品ID _currentSelectedProductId = selectedProductDetail.id; PurchaseParam purchaseParam = PurchaseParam( productDetails: selectedProductDetail, applicationUserName: null, sandboxTesting: true, ); // 原有购买逻辑保留 if (selectedProductDetail.id == _kConsumableId) { _connection.buyConsumable( purchaseParam: purchaseParam, autoConsume: _kAutoConsume || Platform.isIOS); } else { _connection.buyNonConsumable(purchaseParam: purchaseParam); } },
3. 历史交易初始化逻辑改造
替换initStoreInfo中处理queryPastPurchases返回结果的片段,对所有历史交易做强制收尾,从根源解决挂起交易导致的重复异常:
final QueryPurchaseDetailsResponse purchaseResponse = await _connection.queryPastPurchases(); if (purchaseResponse.error != null) { // 原有错误处理逻辑保留 } final List<PurchaseDetails> verifiedPurchases = []; for (PurchaseDetails purchase in purchaseResponse.pastPurchases) { final purchaseKey = purchase.purchaseID ?? purchase.productID; // 历史交易直接标记为已处理,避免后续Stream推送时重复触发逻辑 _processedPurchaseIds.add(purchaseKey); if (await _verifyPurchase(purchase)) { verifiedPurchases.add(purchase); } // 不管验证通过与否,只要存在未完成的交易状态,主动调用completePurchase收尾 if (purchase.pendingCompletePurchase) { await InAppPurchaseConnection.instance.completePurchase(purchase); } }
同时在initState方法开头增加状态重置代码,避免页面重建时残留旧状态:
@override void initState() { // 新增:重置临时状态 _processedPurchaseIds.clear(); _isUploadingReceipt = false; _currentSelectedProductId = null; // 原有初始化逻辑保留 DateTime currentDate = DateTime.now(); // ... 其余原有代码不变 }
4. 购买流监听逻辑改造
重写_listenToPurchaseUpdated方法,增加交易去重、上报范围限制,从根源解决updateUserSubscription多次触发的问题:
void _listenToPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) { purchaseDetailsList.forEach((PurchaseDetails purchaseDetails) async { final purchaseKey = purchaseDetails.purchaseID ?? purchaseDetails.productID; // 已处理过的交易直接跳过,仅做必要的收尾 if (_processedPurchaseIds.contains(purchaseKey)) { if (purchaseDetails.pendingCompletePurchase) { await InAppPurchaseConnection.instance.completePurchase(purchaseDetails); } return; } if (purchaseDetails.status == PurchaseStatus.pending) { showPendingUI(); return; } if (purchaseDetails.status == PurchaseStatus.error) { handleError(purchaseDetails.error); // 错误交易标记为已处理,完成收尾 _processedPurchaseIds.add(purchaseKey); if (purchaseDetails.pendingCompletePurchase) { await InAppPurchaseConnection.instance.completePurchase(purchaseDetails); } return; } if (purchaseDetails.status == PurchaseStatus.purchased) { bool valid = await _verifyPurchase(purchaseDetails); // 所有验证完成的交易先标记为已处理 _processedPurchaseIds.add(purchaseKey); if (!valid) { _handleInvalidPurchase(purchaseDetails); // 无效交易完成收尾 if (purchaseDetails.pendingCompletePurchase) { await InAppPurchaseConnection.instance.completePurchase(purchaseDetails); } return; } // 交付商品逻辑保留 await deliverProduct(purchaseDetails); // 仅对用户当前主动发起的购买、且无正在进行的上报时,才调用后端上报方法 if (purchaseDetails.productID == _currentSelectedProductId && !_isUploadingReceipt) { _isUploadingReceipt = true; // 调用后端校验接口更新订阅状态 await updateUserSubscription(purchaseDetails); // 上报完成后重置临时状态 _isUploadingReceipt = false; _currentSelectedProductId = null; // 校验通过后引导用户进入会员权益页面的逻辑写在这里 } // 原有安卓消耗品处理逻辑保留 if (Platform.isAndroid) { if (!_kAutoConsume && purchaseDetails.productID == _kConsumableId) { await InAppPurchaseConnection.instance.consumePurchase(purchaseDetails); } } // 所有逻辑处理完成后,必须调用completePurchase收尾,杜绝挂起交易 if (purchaseDetails.pendingCompletePurchase) { await InAppPurchaseConnection.instance.completePurchase(purchaseDetails); } } }); }
额外注意事项
- 所有走完校验、交付、上报流程的交易,必须调用
completePurchase完成StoreKit的交易闭环,否则会持续残留到交易队列,触发后续的重复报错 - 沙箱模式下的交易恢复逻辑不要和用户主动购买逻辑混同,自动续费、跨设备恢复的交易只做权益同步,不重复弹引导窗
- 生产环境建议将
sandboxTesting参数通过编译环境配置控制,不要硬编码为true
内容的提问来源于stack exchange,提问作者Jason Waku
相关产品推荐
相关产品推荐

