ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter支付双轨:微信支付与iOS IAP全流程实战解析

Flutter支付双轨:微信支付与iOS IAP全流程实战解析

做Flutter开发这几年,最怕的需求清单里一定有支付。尤其是那种一个需求里同时挂着“微信支付”和“iOS IAP”的,外行看着都是掏钱,内行知道这是两条完全不同的技术路线。微信支付走的是第三方支付SDK,调起的是微信客户端,钱先经过商户平台;iOS IAP走的是苹果内购,调起的是App Store的支付面板,苹果要抽成,还强制虚拟商品必须走它。两者在Flutter里各自的接入方式、回调机制、服务端验签逻辑都不一样,混在一起写,稍不注意就埋雷。

这篇文章我会按真实项目的接法来写:先把两种支付的边界和整体架构说清楚,再分别给出微信支付和IAP在Flutter里的接入流程、关键代码、回调处理,最后把联调里常见的坑全部摊开。内容更适合正在做支付模块、或者准备从原生支付迁移到Flutter统一支付层的开发者参考,不管你是刚接手半个项目还是一路从零搭,至少能少踩几个不是文档里能看到的坑。

1. 动手前的全局设计:平台规则与支付方式划分

1.1 业务边界不能含糊:虚拟商品与实体商品

苹果对App内虚拟商品的定义很宽泛:金币、会员、解锁功能、在线课程、虚拟礼物,基本都算数字内容,必须走IAP。实物商品、线下服务、实体订单这类场景才可以接微信支付、支付宝这些外部支付渠道。这条边界线一旦划错,后果不是用户付不了钱,而是App直接被拒审核、被下架警告,甚至支付功能被封禁。

我在给某个模拟项目X做支付模块时,第一件事不是写代码,而是拉着产品一起把商品分类表过了一遍:哪些商品是“虚拟权益”,哪些是“实物或线下服务”。表格分三列:商品ID、类型、支付渠道。虚拟商品全部指向IAP,实物商品指向微信支付/支付宝。这个动作可能看起来有点无用,但实际后来所有代码都是围绕这张表展开的,改一个商品就查这张表,不会出现某个商品既走微信又走IAP的混乱。

实际开发中还有一个经常被忽略的细节:哪怕你的App只卖虚拟商品,也要保留微信支付的接入代码,因为Android市场里微信支付是合理的。于是同一个Flutter工程里,两个支付SDK会同时存在,只是iOS端的路由判断拦住了微信支付。这个路由判断建议放在服务端配置里下发,不要写死在客户端,方便以后运营调整。

1.2 支付链路分层:客户端只发起,服务端才可信

支付架构最常见的问题是把“支付成功”的判断交给客户端。实际上,客户端收到的成功回调只代表“用户操作完成”,不代表钱已经到账,中间还有异步对账、平台通知、服务端验签这些环节。一个可靠的支付链路应该分四层:

  1. 客户端发起:拿着商品ID请求业务服务端,创建本地订单。
  2. 服务端对接支付平台:微信支付是调用统一下单接口拿预支付参数,IAP则是等客户端提交苹果收据后调苹果验证接口。
  3. 客户端执行支付:微信调起微信客户端,IAP调起App Store支付面板。
  4. 服务端确认到账:微信通过异步通知给服务端,苹果通过收据验证给服务端,服务端验签成功后改订单状态再发货。

这四层里,客户端层最透明,也是最不值得信任的一层。谁都能用抓包工具改回调,所以服务端必须作为最终信任锚。客户端收到成功回调后,只能刷新页面、展示进度,真正决定“是否发放权益”的必须是服务端。

1.3 插件选型:现成方案与统一封装

Flutter接入微信支付,绝大多数人用的是社区插件。微信官方没有提供长期维护的Flutter SDK,所以社区里fluwx和wechat_kit是比较常见的选择。选插件的标准我一般看三条:是否适配最新微信SDK,是否同时支持iOS和Android,是否有持续更新。长期不更新的插件在新Xcode版本上很容易编译报错,这是最让人头疼的问题。

IAP方面,in_app_purchase这个包已经被Flutter官方收纳维护,支持iOS和Android双端,底层分别封装了StoreKit和Google Play Billing。它的API相对稳定,文档也算清楚,是目前首选。

无论选哪个插件,都建议在业务层再包一层自己的支付抽象。原因很简单:两个插件的参数结构、回调对象完全不一样,如果业务代码直接引用插件API,后面换插件或者加一个支付宝,改动量会非常吓人。我之前封装时定义了一个统一的支付结果对象,包含订单号、支付渠道、状态三个字段,业务层只认这三个字段,具体是微信还是IAP的细节隔离在底层实现里。

2. Flutter接入微信支付完整实操

2.1 前置准备:商户号、AppID与Universal Links

微信支付接入的准备工作比写代码磨人。先要有营业执照去申请微信支付商户号,拿到商户号和API密钥;然后在微信开放平台创建移动应用,拿到AppID;最后把移动应用的AppID和商户号绑定。这个流程通常要走几天,而且要求App已经能正常编译、包名和签名都确定下来。

iOS端微信支付还有一步特别容易出错:Universal Links配置。微信SDK在iOS 9之后的版本优先走Universal Links回调,不配置好会出现两种情况:要么初始化SDK时直接报错,要么调起微信付完款后回不到App。配置步骤大致是:

  • 在Apple Developer后台给App ID开启Associated Domains能力。
  • 在Xcode里给Target添加Associated Domains,填入applinks:你的域名。
  • 在域名根目录放好apple-app-site-association验证文件。
  • 在微信开放平台后台配置对应的Universal Links。

Android端相对简单,但要记住包名必须和开放平台登记的一致,还要把应用签名填对。另外需要在AndroidManifest里配置一个固定路径的Activity用来接收微信回调,包名路径下新建wxapi包,放一个WXPayEntryActivity。如果漏了这一步,支付完一切都像石沉大海。

2.2 Flutter端注册SDK与调起支付

注册微信SDK一般在App启动时做,代码不复杂,但要注意必须在微信后台配置好Universal Links之后才测。

import 'package:fluwx/fluwx.dart'; Future<void> initWechatSdk() async { const Fluwx fluwx = Fluwx(); await fluwx.register( appId: 'wxYourAppId', universalLink: 'https://yourdomain.com/app/', ); }

注册完成之后,调起支付需要服务端先完成统一下单。服务端拿到prepay_id后,要根据微信支付文档生成一组调起参数返回给客户端。客户端拿到参数后原样透传,自己不要做任何改动。典型代码是这样的:

Future<void> startWechatPay({ required String appId, required String partnerId, required String prepayId, required String nonceStr, required String timeStamp, required String sign, }) async { final bool installed = await isWeChatInstalled(); if (!installed) { throw Exception('当前设备未安装微信,无法使用微信支付'); } await pay( appId: appId, partnerId: partnerId, prepayId: prepayId, package: 'Sign=WXPay', nonceStr: nonceStr, timeStamp: timeStamp, sign: sign, ); }

这里有一个很容易被误解的点:客户端传下去的sign是服务端算好的,客户端只是搬运工。有人在客户端又算了一遍签名,反而搞得签名对不上,各种-1错误。签名算法是MD5还是HMAC-SHA256,取决于商户后台的配置,服务端用什么,客户端就原样透传什么。

2.3 回调链路:客户端结果、服务端异步通知与二次验签

微信支付完成之后,微信客户端会通过Universal Links回到你的App,SDK底层会触发回调。fluwx把原生回调包装成了Dart流,业务层通过监听拿到支付结果。

fluwx.response.listen((response) { if (response is WeChatPayResponse) { if (response.errCode == 0) { // 只能代表支付流程走完了,服务端二次确认后才算真正成功 _pollOrderStatus(); } else { // errCode 常见值:-1 签名错误,-2 用户取消 } } });

但是在后端逻辑里,绝不能因为拿到errCode == 0就给用户发货。微信支付真正的确认渠道是服务端收到的异步通知。微信会在支付成功后发起多次异步通知,直到服务端返回成功应答。服务端需要验签、核对订单金额、核对商户号,然后才把订单状态改成已支付。客户端最多拿这个回调触发“查单”动作。

实操中我还会做一层兜底:客户端每次从后台回到前台,如果该订单仍是未支付状态,就重新向服务端查询订单状态。这样可以处理异步通知延迟、客户端回调丢失、用户支付成功但没回App等情况。

3. iOS IAP内购接入完整实操

3.1 App Store Connect配置与沙箱测试账号

IAP在苹果后台的配置比微信更琐碎。需要先在App Store Connect里创建App内购买项目,选择类型。类型有四种:消耗型项目、非消耗型项目、自动续订订阅、非续订订阅。选错类型很麻烦,因为审核时对类型的匹配很严格。比如“临时会员”和“永久解锁”混用,审核员经常会提出质疑。

创建项目时要填产品ID,一般用反向域名格式,比如com.example.product。这个ID会在客户端查询商品时用到,也是服务端校验时用于核对订单的关键字段。价格等级、本地化描述这些也要配好,测试账号才能查到商品。

沙箱测试账号需要在App Store Connect里单独创建,路径在“用户和访问”下的“沙箱”标签。测试账号不需要真实支付方式,是苹果提供的虚拟环境。测试流程有一点很迷惑:需要在真机设置里退出真实App Store账号,然后在App内发起购买时,苹果会弹出登录框要求输入沙箱账号。如果你没退出真实账号,就会看到“购买无法完成”之类的报错。

另外,Xcode在较新版本里提供StoreKit测试配置,可以在本地模拟购买成功、取消、订阅续费等各种场景,不用一直往后台跑。开发期我会推荐先用这个,跑通流程后再到沙箱环境做真机验证。

3.2 Flutter端商品查询与购买流程

in_app_purchase的接入大致分三步:查询商品、发起购买、监听结果。

查询商品的逻辑是先拿到商品ID集合,然后调queryProductDetails。可以一次性传多个商品ID,返回结果后按需展示。

final InAppPurchase iap = InAppPurchase.instance; Future<ProductDetails?> queryProduct(String productId) async { final ProductDetailsResponse response = await iap.queryProductDetails({productId}); if (response.notFoundIDs.isNotEmpty) { // 商品ID不存在,多半是后台配置没提交审核或ID写错了 } return response.productDetails.isEmpty ? null : response.productDetails.first; }

发起购买时分消耗型和非消耗型,in_app_purchase提供了两个不同的方法。消耗型面向金币、道具,每次购买都是新的;非消耗型面向解锁、去广告,需要做“恢复购买”逻辑。

Future<void> purchase(ProductDetails product) async { final PurchaseParam param = PurchaseParam(productDetails: product); if (product.id == 'com.example.coin') { iap.buyConsumable(purchaseParam: param); } else { iap.buyNonConsumable(purchaseParam: param); } }

苹果的支付面板会自己弹出来,用户操作完成后,结果会进入purchaseStream。这个流是IAP的命脉,所有事务状态都会从这里过来,包括购买成功、失败、等待处理、以及上次没处理完的旧事务。

3.3 事务完成、恢复购买与订阅注意事项

监听purchaseStream是IAP最核心也最容易出错的一步。一个常见错误是看到购买状态变成approved就直接给用户发货,却忘了调用completePurchase。苹果的事务队列是有记忆的,事务如果没有被finish,下次启动App时会再次把这个事务推给你。如果每次推送都发一次货,就会出现“一笔订单重复到账”的严重事故。

正确的处理顺序是:收到approved状态后,先把收据数据交给服务端,服务端验签、发货、改订单,客户端收到服务端确认响应后,再调用completePurchase完成事务。

iap.purchaseStream.listen((details) async { for (final PurchaseDetails detail in details) { if (detail.status == PurchaseStatus.approved) { // 把serverVerificationData发给服务端 final bool confirmed = await _server.confirmIapOrder( detail.purchaseID, detail.verificationData.serverVerificationData, ); if (confirmed) { await iap.completePurchase(detail); } else { // 服务端校验失败,不要finish,方便排查 } } else if (detail.status == PurchaseStatus.error) { // 展示错误或取消逻辑 } } });

恢复购买是另一个必修功能。用户在A设备买了解锁,换到B设备时应该能恢复。in_app_purchase提供了restorePurchases方法,非消耗型和自动续订订阅必须做好恢复逻辑,苹果审核时很容易抽查这一点。恢复回来的订单同样要走服务端校验,不能因为restored状态就盲目放行。

订阅类IAP比普通内购多了管理页、退订协议、隐私政策等要求,服务端也要处理续订周期内的receipt更新。如果项目里只有一次性内购,这部分可以先不做,但如果做订阅,建议直接上苹果新一代的App Store Server API,配套的交易签名信息更适合订阅场景。

3.4 服务端收据校验:沙箱与生产环境的坑

IAP服务端校验是必做的,而且必须放在服务端,不要放在客户端。客户端直接把收据交给苹果验证的话,攻击者可以改请求、伪造响应,绕过支付。

旧式的校验方式是调用苹果的verifyReceipt接口,提供两个正式环境地址:

  • 沙箱环境:https://sandbox.itunes.apple.com/verifyReceipt
  • 生产环境:https://buy.itunes.apple.com/verifyReceipt

服务端拿到客户端传上来的base64收据字符串后,先按生产环境验证。如果返回21007,说明这是一张沙箱票据,再切到沙箱地址验一次。反过来,如果先用沙箱地址验过了正式票据,会得到21008。

验证通过后,一定要检查返回JSON里的bundle_id、product_id、purchase_date_ms这些字段。很多支付漏洞就是这么来的:商品A的收据拿去解锁商品B,服务端没核对product_id,等于白送。我之前遇到过一次线上问题,就是服务端只验了签名没核对商品ID,导致用户低价商品收据换高价商品权益,而且持续了好几天才被发现。

新项目如果条件允许,建议考虑App Store Server API。它能拿到更高阶的交易信息,而且对退款、续订、订阅状态查询的支持也更好,长期看比老接口省心。

4. 常见问题与排查技巧实录

4.1 支付问题速查表

现象可能原因排查建议
微信支付调起后立即返回失败,errCode=-1签名错误/参数缺失对比服务端生成sign的参数与客户端透传参数是否一致
iOS微信支付调不起来,提示未安装测试机未装微信调用isWeChatInstalled提前判断并提示
微信支付成功但App没收到回调Universal Links未配置/WXPayEntryActivity缺失检查后台配置、域名验证文件和Android回调Activity
IAP商品查询返回空商品ID未在后台创建/未提交审核/沙箱账号未登录检查ID拼写、商品审核状态
IAP购买pending或收不到确认存在未完成事务启动时重新监听purchaseStream并逐个处理
服务端验receipt返回21007沙箱票据发到了生产环境先生产后沙箱的自动切换逻辑
用户重复获得权益未调用completePurchase或服务端未按transactionId去重服务端落库去重,客户端确认后finish事务

4.2 微信支付联网排查实录

微信支付回调errCode=-1是出现频率最高的问题。很多人一看签名错误,第一反应是去客户端找原因,其实大部分问题出在服务端生成签名时:参与签名的字段名顺序不对、字段值为空、时间戳不是当前时间、nonceStr在两次请求间被重复使用,这些都会造成签名校验失败。

排查方法我建议这样操作:把服务端生成调起参数的原始字段全部打日志,包括prepayId、nonceStr、timeStamp、sign,然后在客户端监听的地方同样打一份透传日志,两边拼起来对。微信官方文档提供在线签名验证工具,把参与签名的字段和密钥填进去,能直接判断是服务端算法问题还是字段传输问题。

还有一类隐蔽问题:服务端下单时用的AppID是A,客户端注册SDK用的AppID是B,或者商户号和AppID没绑定好。这往往不是签名错误,而是初始化就出错,表现为调起微信后就马上失败,但客户端日志里看不到太多异常。检查这一类问题时,把微信开放平台后台的绑定关系重新核对一遍最有效。

4.3 购买成功但权益不到账的排查实录

这类问题在IAP里特别常见,表现是:用户支付成功,苹果也弹了“感谢购买”,但App内金币没涨,会员没解锁。很多人怀疑是苹果回调延迟,其实大概率是本地事务没处理完。

在StoreKit的设计里,事务会一直躺在支付队列里,直到你明确调用finishTransaction。如果你在approved状态后没有走完整流程,或者在服务端确认后才finish这一步没执行,App下一次冷启动时,这个事务会再次出现在purchaseStream里。如果业务代码没有幂等处理,就会发生服务端重复发货或触发重复校验。

我遇到过一起线上事故就是这样:某个消耗型道具购买后,客户端在发送网络请求时超时退出,事务一直没有被finish。用户重新打开App,旧事务又触发了一次服务端发货,导致道具数量翻倍。最后是通过在服务端记录transactionId和本地订单号的关系来解决的,同一个transactionId只允许发一次货,同时客户端在拿到approved后先查一次服务端是否已经处理过这一单。

4.4 沙箱环境与正式货品混乱的表现

IAP在开发期最容易踩的另一个坑是沙箱环境混淆。苹果的收据分成两类:正式票据和沙箱票据,它们长得一样,但服务端验证地址不一样。如果你的服务端只配了一个地址,经常会出现“真机购买测试商品时服务端返回21002或21003”的报错。

解决方案就是在服务端写一个环境切换逻辑:先用生产环境尝试验证,如果返回21007,则换用沙箱环境再验一次。还有一种反向情况,正式上线的包不小心用了沙箱验证地址,用户付款成功但服务端永远验不过去,订单一直卡在“已扣款未发货”状态。这一类问题光看日志比较难受,因为票据本身并没有异常,只有环境不匹配。我的做法是在服务端验签日志里同时记录来源IP、环境标识、返回码,三件套齐了定位就很快。

4.5 Android微信支付收不到回调的典型原因

微信支付在Android端收不到回调,最常见的原因是WXPayEntryActivity没有配置,或者配置的包名和实际包名不一致。Android的微信回调地址是个固定约定:如果你的应用包名是com.example.app,微信SDK会去找com.example.app.wxapi.WXPayEntryActivity。很多人把Activity放在自己的业务目录里,回调自然不回来。

另一个原因是应用签名没有登记到微信开放平台。微信支付回调是建立在签名匹配前提下的。打包测试机用的是debug签名,开放平台登记的是release签名,也会出现支付成功但App没反应的情况。最稳妥的方式是用微信官方提供的签名获取工具,把签名拿回来后重新配置。

5. 支付模块的一些实操心得

5.1 日志链路一定要完整

支付联调最怕的就是信息黑盒:客户端说“我发起了”,服务端说“我没收到回调”,两边各执一词。后来我在系统里强制要求所有支付相关事件都带同一个订单号落日志,从客户端点击支付、服务端创建订单、平台返回预支付参数、客户端透传、服务端异步通知验签,到最终订单完结,整条链路一条日志都不能少。这样排查时能直接按订单号串起来看,问题定位时间会快很多。

小米加步枪的打法也有一个:在预发环境里做一个本地模拟支付入口。界面上的“支付”按钮可以切换成“模拟成功”,点击后直接调用服务端的确认接口完成订单闭环。这样UI测试、自动化测试都不需要真的拉起外部App,也不用反复消耗苹果沙箱环境。

5.2 安全闸门再强调也不为过

支付模块不是普通业务模块,它直接涉及金额和权益,安全策略必须从设计阶段就想清楚。客户端的回调结果永远只是展示素材,服务端必须完成验签、金额核对、商品核对之后才能发货。微信支付要核对订单金额和商户号,IAP要核对product_id和bundle_id,这些都是老生常谈但永远有人踩的坑。

我会建议在服务端增加一个独立的对账任务,每天跑一次:把本地订单表和微信支付账单、App Store财务报告做三方比对。哪怕线上没有实时问题,也能通过对账发现漏单、错单。支付没有“上线之后就轻松”的说法,对账机制才是生命线。

5.3 最后再分享一个小技巧

接入支付时可以顺带做一个通用能力:服务端下发“允许的支付渠道列表”。比如iOS只允许IAP,Android允许微信支付和支付宝,海外版可能只允许IAP。客户端启动时拉取这份配置,按配置渲染支付按钮。这样后续要调整某个商品的下架或切换支付渠道,不需要发版,服务端改一下就可以。我在几个项目里用了这个模式后,产品提渠道需求时,基本不用再动客户端代码了。

支付大概是我接触过的模块里,最考验“耐心”的一个。它不像UI界面那样写完就能看到效果,每一步都要考虑失败分支、重复回调、服务端验签、线上对账。但只要架构分得清、回调链路完整、服务端把控住安全底线,这套东西其实可以沉淀成通用方案,后续每个新项目直接复用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进