开篇:用户说"我付了钱但会员没到账"
内购是应用里最容易出事故的功能:用户扣款成功,权益没开通;订阅自动续费了,应用还显示已过期;换台手机登录,购买记录全丢了。这些问题几乎都指向同一个根因——把"支付成功"当成了"权益开通"。
应用内购买的本质是一条跨越客户端、应用商店、业务服务端的三段链路。客户端只负责发起购买并拿到收据,真正的权益判定必须由服务端向商店校验收据后才能确认。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 平台通道 — 第三方支付 SDK 与原生桥接的实现方式
- Flutter 安全加固 — 收据与订单数据的传输与存储保护
- Flutter 错误处理与可靠性 — 校验失败与网络重试的兜底策略
- Flutter 异步与网络 — 校验请求与订单确认的网络层设计
- Firebase 集成 — 云函数侧完成收据校验与权益发放
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。