《支付与内购:应用内购买、订阅与第三方支付》

从 in_app_purchase 插件的商品查询、购买流程与收据校验,到订阅的续订、宽限期与恢复购买,再到第三方支付的接入边界与合规红线,系统梳理 Flutter 支付与内购的完整链路、服务端校验设计与常见踩坑清单。

开篇:用户说"我付了钱但会员没到账"

内购是应用里最容易出事故的功能:用户扣款成功,权益没开通;订阅自动续费了,应用还显示已过期;换台手机登录,购买记录全丢了。这些问题几乎都指向同一个根因——把"支付成功"当成了"权益开通"。

应用内购买的本质是一条跨越客户端、应用商店、业务服务端的三段链路。客户端只负责发起购买并拿到收据,真正的权益判定必须由服务端向商店校验收据后才能确认。Flutter 侧用 in_app_purchase 插件封装 StoreKit 与 Google Play Billing,第三方支付则通过原生 SDK 或平台通道接入。本文沿着体系与合规、IAP 接入、订阅管理、服务端校验、第三方支付、测试踩坑这条链路展开。


一、支付体系全景与合规

1.1 虚拟商品与实体商品的边界

应用商店的抽成规则决定了两类支付必须分开处理:虚拟商品(会员、道具、去广告)必须走应用内购买,实体商品(外卖、打车、实物电商)可以使用第三方支付。

商品类型支付通道抽成举例
虚拟商品应用内购买有会员、金币、皮肤
订阅服务应用内购买订阅有月度会员
实体商品第三方支付无外卖、实物订单
线下服务第三方支付无打车、到店服务

违反这条边界(虚拟商品走第三方支付)会导致上架被拒甚至下架,这是最硬的红线。

1.2 插件与依赖

# pubspec.yaml
dependencies:
  in_app_purchase: ^3.2.0
  in_app_purchase_storekit: ^0.3.16
  in_app_purchase_android: ^0.3.6
  http: ^1.2.2

1.3 三种商品类型

  • 消耗型:金币、次数包,购买后可重复购买,用完需调用 consume。
  • 非消耗型:永久去广告、终身会员,购买一次永久有效。
  • 订阅型:按月或按年自动续费,有续订、宽限期、退款等复杂状态。

一句话总结:先判断商品是虚拟还是实体,再判断是消耗、非消耗还是订阅,商品类型定错,后面全错。


二、in_app_purchase 接入

2.1 初始化与商品查询

final iap = InAppPurchase.instance;

final available = await iap.isAvailable();
if (!available) return;

const ids = <String>{'vip_monthly', 'coins_100'};
final response = await iap.queryProductDetails(ids);

for (final product in response.productDetails) {
  debugPrint('${product.title} - ${product.price}');
}

2.2 监听购买流

purchaseStream 是全流程的唯一入口:购买、恢复、错误都从这里回来。必须在应用启动时就开始监听,避免漏掉事件。

final subscription = iap.purchaseStream.listen(
  (purchases) async {
    for (final purchase in purchases) {
      switch (purchase.status) {
        case PurchaseStatus.pending:
          showLoading();
        case PurchaseStatus.purchased:
          await verifyOnServer(purchase); // 交给服务端校验
        case PurchaseStatus.restored:
          await verifyOnServer(purchase);
        case PurchaseStatus.error:
          showError(purchase.error);
        case PurchaseStatus.canceled:
          hideLoading();
      }
    }
  },
  onDone: () {},
);

2.3 发起购买与完成交易

final param = PurchaseParam(productDetails: product);
await iap.buyNonConsumable(purchaseParam: param); // 非消耗型
// 消耗型用 buyConsumable,订阅用 buyNonConsumable

// 服务端校验通过后,必须显式完成交易
await iap.completePurchase(purchase);

消耗型商品在校验并发放权益后还要调用 consumePurchase,否则同一商品无法再次购买。

一句话总结:purchaseStream 是唯一可信的事件源,服务端校验通过前不要给用户任何权益。


三、订阅与恢复购买

3.1 订阅的生命周期

订阅不是一次购买,而是一个持续状态:首次购买、自动续订、续订失败进入宽限期、宽限期后进入账号保留期、最终过期或退款。应用必须能正确映射这些状态。

状态含义应用表现
活跃订阅有效展示会员权益
宽限期扣款失败但仍有效仍展示权益,提示更新支付方式
账号保留期权益停止,可恢复停止权益,提示续费
已过期订阅结束恢复免费版
已退款用户退款收回权益

3.2 恢复购买

换设备、重装应用后,非消耗型与订阅必须提供"恢复购买"入口,且这是应用商店审核的硬性要求。

Future<void> restorePurchases() async {
  await InAppPurchase.instance.restorePurchases();
  // 结果仍会通过 purchaseStream 返回,状态为 restored
}

3.3 服务端通知

订阅状态变化最可靠的来源是服务端通知(App Store Server Notifications 与 Google Real-time Developer Notifications)。客户端状态查询只作为兜底,不能作为唯一依据。

一句话总结:订阅的真实状态由服务端持有,客户端只是展示层,任何"以客户端为准"的设计都会在续费与退款场景出错。


四、服务端校验与收据

4.1 为什么必须服务端校验

客户端校验(本地解析收据)可以被篡改,且无法验证收据是否已被使用。必须把收据交给业务服务端,由服务端向 Apple 或 Google 的校验接口发起请求。

客户端 → 发起购买 → 商店
客户端 ← 收据 ← 商店
客户端 → 收据 + 用户身份 → 业务服务端
业务服务端 → 校验请求 → 商店校验接口
业务服务端 ← 校验结果 ← 商店
业务服务端 → 开通权益 → 客户端

4.2 校验要点

  • 校验时同时提交 transactionId,服务端做幂等处理,防止重复开通。
  • 校验通过后记录原始交易 ID,作为对账与客服依据。
  • 订阅校验要检查 expires_date,并保存最新到期时间。
  • 沙盒环境收据要用沙盒校验地址,生产用生产地址。
Future<void> verifyOnServer(PurchaseDetails purchase) async {
  final body = {
    'platform': purchase.verificationData.source,
    'receipt': purchase.verificationData.serverVerificationData,
    'productId': purchase.productID,
    'transactionId': purchase.purchaseID,
  };
  final ok = await api.post('/iap/verify', body);
  if (ok) {
    await InAppPurchase.instance.completePurchase(purchase);
  }
}

4.3 权益发放的幂等设计

网络重试、客户端重复上报都会导致同一笔交易被多次提交。服务端必须以交易 ID 作为唯一键,重复请求直接返回已有结果。

Future<void> grantBenefits(String transactionId) async {
  // 服务端伪代码:以交易 ID 为唯一键做幂等
  final exists = await db.findByTransactionId(transactionId);
  if (exists != null) return; // 已处理,直接返回
  await db.insertAndGrant(transactionId);
}

一句话总结:客户端永远不可信,收据校验与幂等发放是内购系统的两条生命线。


五、第三方支付接入

5.1 接入方式

第三方支付(微信、支付宝、Stripe)没有官方 Flutter 插件时,需要写平台通道或使用社区插件。核心流程是:客户端唤起支付 → 用户在支付应用完成付款 → 支付应用回调本应用 → 客户端把结果交给服务端确认。

const channel = MethodChannel('app/payment');

Future<void> pay(String orderId) async {
  final result = await channel.invokeMethod<String>('pay', {
    'orderId': orderId,
    'amount': 100,
  });
  // 客户端回调结果同样不可信,必须服务端查询订单
  await api.post('/order/confirm', {'orderId': orderId});
}

5.2 回调可靠性

第三方支付的客户端回调不可靠:用户可能杀掉应用、网络可能中断。可靠的方案是"服务端异步通知为主,客户端查询为辅"。

来源可靠性用途
客户端回调低即时反馈
服务端异步通知高最终确认
主动订单查询高兜底补偿

客户端在拿到回调后,应轮询几次订单状态作为兜底,而不是只弹一个"支付成功"就结束。

Future<void> pollOrder(String orderId) async {
  for (var i = 0; i < 5; i++) {
    final status = await api.get('/order/$orderId/status');
    if (status == 'paid') {
      openBenefits(orderId);
      return;
    }
    await Future.delayed(const Duration(seconds: 2));
  }
  showPendingTip(); // 超时后提示用户稍后查看
}

5.3 合规注意

  • 虚拟商品不得使用第三方支付,这是应用商店审核的硬红线。
  • 支付相关页面不得出现诱导站外支付的文案。
  • 涉及资金的功能要做好日志留存,便于对账与纠纷处理。

一句话总结:第三方支付的客户端回调只用来更新 UI,真正的订单状态必须以服务端通知为准。


六、测试与踩坑清单

6.1 测试环境

  • iOS 使用 StoreKit Configuration 文件在 Xcode 中本地测试,无需真实沙盒账号。
  • Android 使用 Play 控制台的内测轨道与测试账号,必须用测试账号才能免费购买。
  • 沙盒环境的订阅周期会被压缩(如 1 个月按几分钟计),便于测试续订。

6.2 常见踩坑清单

  • 服务端校验未做幂等:用户被重复开通或重复扣权益。
  • 忘记 completePurchase:交易一直处于未完成状态,反复回调。
  • 消耗型未 consumePurchase:同一商品无法二次购买。
  • 缺少"恢复购买"入口:审核被拒,用户换机丢权益。
  • 订阅状态只看客户端:退款或宽限期场景判断错误。
  • 沙盒与生产校验地址混用:正式环境校验失败。
  • 未监听 purchaseStream 的 onDone:异常断流后无法恢复。
  • 商品 ID 写错或未在后台创建:查询返回空列表。

6.3 对账与客服

内购问题最终都要靠对账解决:服务端保存每笔交易的交易 ID、商品 ID、用户 ID、时间与状态,客服按用户或订单查询即可快速定位。建议同时接入商店的服务端通知,把退款与续订变化实时同步。

一句话总结:内购系统的健壮性来自"服务端校验 + 幂等发放 + 对账留痕",三者齐备才能扛住线上纠纷。


FAQ

常见问题:虚拟商品可以用微信或支付宝支付吗?

答:不可以。应用商店明确规定虚拟商品与服务必须使用应用内购买,使用第三方支付会被拒审甚至下架。实体商品与线下服务才允许使用第三方支付。

常见问题:为什么购买后权益没有立即到账?

答:正常流程需要"客户端拿到收据、服务端向商店校验、服务端发放权益"三步。到账延迟通常是服务端校验请求超时或失败。应在前端给出"处理中"提示,并在校验失败时提供重试,而不是直接报错。

常见问题:用户换手机后购买记录丢失怎么办?

答:必须提供"恢复购买"功能,调用 restorePurchases 并监听 purchaseStream 的 restored 状态。这也是应用商店审核的硬性要求,缺少该入口会被拒。

常见问题:订阅续费了但应用显示已过期?

答:说明应用只依赖客户端的本地到期时间。正确做法是以服务端保存的到期时间为准,并通过 App Store Server Notifications 与 Google 实时开发者通知实时同步续订、退款与宽限期状态。

常见问题:沙盒测试时购买是免费的吗?

答:是的。iOS 沙盒与 Android 测试账号下的购买不会真实扣款,但会走完整流程并产生收据。注意沙盒收据必须用沙盒校验地址,混用生产地址会导致校验失败。

常见问题:同一笔交易被重复上报会怎样?

答:如果服务端没有幂等设计,会导致重复发放权益。必须用交易 ID 作为唯一键,重复请求直接返回已有结果,同时在客户端也要避免重复调用 completePurchase。

常见问题:订阅的宽限期是什么,需要处理吗?

答:宽限期是扣款失败后商店仍保持订阅有效的缓冲期。应用在宽限期内应继续提供权益,同时提示用户更新支付方式;不做处理会导致用户以为权益被无故收回而投诉。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Flutter」更多文章

  1. 《GraphQL 与 gRPC 客户端:类型安全 API 与代码生成》
  2. 《图表与数据可视化:fl_chart、自绘图表与实时数据展示》
  3. 《地图与定位服务:地图 SDK、定位与地理围栏》