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,后端需要:
- 使用受信任的 Apple 根证书,验证签名和证书链。
- 校验
bundleId、环境以及所用验证库要求的应用标识。 - 校验商品是否属于本应用允许的订阅商品。
- 校验交易与当前业务账号的绑定关系。
- 保存验证后的交易,再查询、计算当前订阅权益。
可以使用 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 应分别进入对应的验证流程。
后端如何确定最新且有效的订阅?
后端收到历史交易后,应将它作为查询订阅的线索,避免直接把账户状态改成这笔交易的状态。
处理流程可以分为以下几步:
- 验证客户端提交的数据,从可信结果中取得交易标识和环境。
- 使用适用的交易标识调用 App Store Server API 的
Get All Subscription Statuses,读取相关订阅组的状态。 - 需要补齐交易历史时,调用
Get Transaction History,处理分页,并验证返回的签名数据。 - 在对应用户、订阅组和交易链范围内计算权益,再持久化结果。
如果业务同时提供月订阅和年订阅,还需要处理同一订阅组内的升级、降级及商品替换,不能只筛选客户端当前选中的 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,并在后端验证绑定关系。
备注:内容仅供参考。
