Flutter支付集成
前言:
以谷歌内购为例,我们需要做的总共为三步
- 需要在谷歌市场配置商品,设置测试渠道,配置开发者账号,设置对应权限。
- 配置完商品之后,如何在 Flutter 中获取到商品,购买指定商品,消耗商品等。
- 购买成功之后,如何到服务器校验是否支付成功,后台服务器如何配置通行权限,谷歌市场与谷歌云的关联以及相关校验。
购买交易的生命周期
下面是一次性购买或订阅的典型购买流程:
- 向用户展示他们可以购买什么。
- 启动购买流程,以便用户接受购买交易。
- 在您的服务器上验证购买交易。
- 向用户提供内容。
- 确认内容已传送给用户。对于消耗型商品,用户要先消耗掉已购商品,才能再次购买。
订阅会自动续订,直到被取消。订阅可处于下面这几种状态:
- 有效:用户信誉良好,可享用订阅内容。
- 已取消:用户已取消订阅,但在到期前仍可享用订阅内容。
- 处于宽限期:用户遇到了付款问题,但仍可享用订阅内容,同时 Google 会重新尝试通过相应的付款方式扣款。
- 暂时保留:用户遇到了付款问题,不能再享用订阅内容,同时 Google 会重新尝试通过相应的付款方式扣款。
- 已暂停:用户暂停了其订阅,在恢复之前不能享用订阅内容。
- 已到期:用户已取消且不能再享用订阅内容。用户在订阅到期时会被视为流失。
支付流程示意图
一 、google开发者平台配置
首先进入谷歌开发者平台
https://developers.google.com/?hl=zh-cn
进入开发者平台之后,点击google play,创建我们的APP
点击登录管理中心
创建完我们的APP之后,就可以开始配置支付的功能。需要注意的是,在进行谷歌支付测试的时候,需要先提交一个封闭测试版本及以上等级(例如公开版本)的包,然后才可以去创建应用内支付的商品,等这个包提交审核通过之后才可以开始进行谷歌支付的测试。
1.1、创建定价模板
在设置页面
找到付款概况之后,如果没有付款账号,我们填写一些信息,姓名,邮箱,账号,等等信息,创建完成之后我们就可以设置定价的模板。
如果能创建模板说明你付款账号没问题,定价模板是非必须的,可有可无,但是定义了模板之后会更加方便,到时候创建商品可以直接关联模板,账号下的每一个子应用的内购商品都能关联对应的模板,有一个统一的定价。
如何创建定价模板如下:
我们创建模板之后,就可以定义模板的价格与标题,选择的金额会有对应的汇率转换,比如我创建的新加坡币,如果用港元支付的话,会根据汇率转换为对应的港元支付。
创建完成之后,我们就能看到对应的定价模板如下图所示:
1.2、上架封闭测试App
点击创建轨道
点击创建新的发布版本
签名选择Google管理签名,然后上传aab格式的release版本的包,aab版本的包在这里生成
点开Build,选择Generate Signed Bundle/APK
然后选择app bundle
然后一路next,最后选择release版本,然后finish
然后在输出控制台的build选项卡,即可找到刚刚打出来的aab包
然后上传就可以了。
1.3、创建应用内购商品
此时就可以配置应用内商品了,点击这里进行添加配置:
添加完成后记得激活,不然即使审核通过之后测试的时候也获取不到该商品
点击这里激活商品
这个时候商品的配置就完成了。
接下来添加测试账户,进入封闭测试页面,切换到【测试用户选项卡】,然后创建测试群组,在群组里添加测试人员账户即可
当你的APP审核通过之后,这个页面下方的测试人员参与方式便会生效,如下所示:
就可以将这些链接发给测试人员,让他们去安装进行测试购买。
最后修改一下测试政策状态
选中测试群组,然后将政策状态改为LICENSED
OK,配置完成
二 、Apple开发者平台添加内购商品
首先使用苹果开发者账户登录苹果开发者平台
https://developer.apple.com/account
点击【App】
添加新的苹果内购商品
添加的时候页面的指引很清晰,就不赘述了,苹果添加内购商品比较简单,加完就可以了。
然后去创建沙盒账户用来做苹果支付测试,回到首页,点击【用户和访问】
点击沙盒,然后添加一个苹果测试账户,这个账户可以是个假的邮箱,不需要是正式的Apple id,比如你可以设置为8888888@qq.com类似之类的账户
添加完点击创建即可
OK,配置完成
三、flutter 代码集成
使用到的官方推出的应用内购插件:
in_app_purchase: ^3.2.0
插件官网地址:https://pub.dev/packages/in_app_purchase
使用起来并不复杂,可以说是 Android 与 iOS 的逻辑是一样样的。
将插件添加至yaml文件,然后执行flutter pub get
执行完了记得去IOS和安卓端分别执行pod install 和 gradle sync同步一下第三方插件
然后在项目中新建dart文件,命名为:BuyEngine.dart
然后将以下代码放入:
import 'dart:async';
import 'dart:io';import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';class BuyEngin{StreamSubscription<List<PurchaseDetails>> _subscription;InAppPurchase _inAppPurchase;List<ProductDetails> _products; //内购的商品对象集合//初始化购买组件void initializeInAppPurchase() {// 初始化in_app_purchase插件_inAppPurchase = InAppPurchase.instance;//监听购买的事件final Stream<List<PurchaseDetails>> purchaseUpdated = _inAppPurchase.purchaseStream;_subscription = purchaseUpdated.listen((purchaseDetailsList) {_listenToPurchaseUpdated(purchaseDetailsList);}, onDone: () {_subscription.cancel();}, onError: (error) {error.printError();print("购买失败了");});}void resumePurchase(){_inAppPurchase.restorePurchases();}/// 加载全部的商品void buyProduct(String productId) async {print("请求商品id " + productId);List<String> _outProducts = [productId];final bool available = await _inAppPurchase.isAvailable();if (!available) {// ToastUtil.showToast("无法连接到商店");print("无法连接到商店");return;}//开始购买// ToastUtil.showToast("连接成功-开始查询全部商品");print("连接成功-开始查询全部商品");List<String> _kIds = _outProducts;final ProductDetailsResponse response = await _inAppPurchase.queryProductDetails(_kIds.toSet());print("商品获取结果 " + response.productDetails.toString());if (response.notFoundIDs.isNotEmpty) {// ToastUtil.showToast("无法找到指定的商品");print("无法找到指定的商品");// ToastUtil.showToast("无法找到指定的商品 数量 " + response.productDetails.length.toString());return;}// 处理查询到的商品列表List<ProductDetails> products = response.productDetails;print("products ==== " + products.length.toString());if (products.isNotEmpty) {//赋值内购商品集合_products = products;}print("全部商品加载完成了,可以启动购买了,总共商品数量为:${products.length}");//先恢复可重复购买// await _inAppPurchase. ();startPurchase(productId);}// 调用此函数以启动购买过程void startPurchase(String productId) async {print("购买的商品id为" + productId);if (_products != null && _products.isNotEmpty) {// ToastUtil.showToast("准备开始启动购买流程");try {ProductDetails productDetails = _getProduct(productId);print("一切正常,开始购买,信息如下:title: ${productDetails.title} desc:${productDetails.description} ""price:${productDetails.price} currencyCode:${productDetails.currencyCode} currencySymbol:${productDetails.currencySymbol}");_inAppPurchase.buyConsumable(purchaseParam: PurchaseParam(productDetails: productDetails));} catch (e) {e.printError();print("购买失败了");}} else {print("当前没有商品无法调用购买逻辑");}}// 根据产品ID获取产品信息ProductDetails _getProduct(String productId) {return _products.firstWhere((product) => product.id == productId);}/// 内购的购买更新监听void _listenToPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) async {for (PurchaseDetails purchase in purchaseDetailsList) {if (purchase.status == PurchaseStatus.pending) {// 等待支付完成_handlePending();} else if (purchase.status == PurchaseStatus.canceled) {// 取消支付_handleCancel(purchase);} else if (purchase.status == PurchaseStatus.error) {// 购买失败_handleError(purchase.error);} else if (purchase.status == PurchaseStatus.purchased || purchase.status == PurchaseStatus.restored) {// ToastUtil.showToast(DataConfig.getShowName("Pay_Success_Tip"));//完成购买, 到服务器验证if (Platform.isAndroid) {var googleDetail = purchase as GooglePlayPurchaseDetails;checkAndroidPayInfo(googleDetail);} else if (Platform.isIOS) {var appstoreDetail = purchase as AppStorePurchaseDetails;checkApplePayInfo(appstoreDetail);}}}}/// 购买失败void _handleError(IAPError iapError) {// ToastUtil.showToast("${DataConfig.getShowName("Purchase_Failed")}:${iapError?.code} message${iapError?.message}");}/// 等待支付void _handlePending() {print("等待支付");}/// 取消支付void _handleCancel(PurchaseDetails purchase) {_inAppPurchase.completePurchase(purchase);}/// Android支付成功的校验void checkAndroidPayInfo(GooglePlayPurchaseDetails googleDetail) async {_inAppPurchase.completePurchase(googleDetail);print("安卓支付交易ID为" + googleDetail.purchaseID);print("安卓支付验证收据为" + googleDetail.verificationData.serverVerificationData);}/// Apple支付成功的校验void checkApplePayInfo(AppStorePurchaseDetails appstoreDetail) async {_inAppPurchase.completePurchase(appstoreDetail);print("Apple支付交易ID为" + appstoreDetail.purchaseID);print("Apple支付验证收据为" + appstoreDetail.verificationData.serverVerificationData);}@overridevoid onClose() {if (Platform.isIOS) {final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition =_inAppPurchase.getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();iosPlatformAddition.setDelegate(null);}_subscription.cancel();}}
至此集成完毕,开始测试谷歌支付
三 、支付测试
在调用支付的地方提前初始化购买插件:
BuyEngin _buyEngin = BuyEngin();
_buyEngin.initializeInAppPurchase();
然后调用即可:
_buyEngin.buyProduct("应用内商品ID");
应用内商品ID就是你在google开发者中心或APP Store Connect 配置的应用内购买商品的product ID
如果一切正常,则会正常唤醒谷歌或苹果支付
支付完成后可以看到可以正常获取到交易的ID和交易的验证收据,为了避免被第三方恶意刷购买接口来进行非法购买,建议将该收据上传后端服务器进行验证,验证通过之后再去更新用户的购买信息。
Ok ,集成完毕,功德+1
四、 服务器校验相关流程
为什么要加后端校验?客户端支付成功了,服务端怎么知道,万一用接口的方式通信,如果被抓包岂不是可以无限加金币了。太不安全了,所以才有服务器校验这一步。
iOS的校验不用说,很简单,拿到支付完成的票据直接发起请求即可,而 Android 的服务端校验就相对麻烦,需要配置谷歌云,以及对应的通行权限。
谷歌结算文档:https://developer.android.com/google/play/billing?hl=zh-cn
谷歌支付校验AI:https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products?hl=zh-cn
如果我们直接在API中调用校验接口,那肯定是直接报错:
{"error": {"code": 403,"message": "The project id used to call the Google Play Developer API has not been linked in the Google Play Developer Console.","errors": [{"message": "The project id used to call the Google Play Developer API has not been linked in the Google Play Developer Console.","domain": "androidpublisher","reason": "projectNotLinked"}]}
}
没有授权,接下来开始授权
4.1、Google Cloud关联
首先需要配置 Google Cloud 并且配置相关的账号,对应指定的应用。
点击项目的 API Access 中
如果这一步你没有 Google Cloud 账号,可以创建或关联已有的 Google Cloud 账号,这里我没有就直接创建了Google Cloud 账号。关联之后我们就能看到上图所示的画面。
我们可以直接在谷歌市场控制台中的 API Access 中直接进入谷歌云后台,也能 直接输入网址 https://code.google.com/apis/console/ 是一样的效果。
我们关联 Google Cloud 账号之后,默认就已经开通 Google Play Developer API 权限。
所以我们不需要再次去授权了。
如果觉得不保险,也能在里面搜索 Billing ,然后启动相关的支付服务权限
4.2 、创建 web-OAuth 授权
当我们在谷歌市场的后台关联谷歌云的时候,就已经帮我们初始化了很多配置,已经都有了
我们再谷歌云后台,在APIs & auth 项中找到 Credentials,直接查看即可:
我们点击 Web 授权进去配置相关配置。
主要是配置左侧的上下两个 URI 地址,上面的配置后台域名:
下面的是固定写法,callback的地址一定是可用域名 + /oauth2callback。
创建完成之后,记得记录你的三个重要字段,client_id 和 client_secret 以及 redirect_uri ,后面会用到。
通过访问一下的网页获取到一个oauth2callback:
https://accounts.google.com/o/oauth2/auth?scope=https://www.googleapis.com/auth/androidpublisher&response_type=code&access_type=offline&
redirect_uri=https://api.whatsapp.sg/oauth2callback&client_id=816630003638-5p27m684jfpfa6sh6l9chbpreq2hg9ov.apps.googleusercontent.com
返回一个code:
https://api.whatsapp.sg/oauth2callback?code=4/CpVOd8CljO_gxTRE1M5jtwEFwf8gRD44vrmKNDi4GSS.kr-GHuseD-oZEnp6UADFXm0E0MD3FlAI
拿到后面的 code 字段。
code=4/CpVOd8CljO_gxTRE1M5jtwEFwf8gRD44vrmKNDi4GSS.kr-GHuseD-oZEnp6UADFXm0E0MD3FlAI
我们手动的在 postman 之类的工具上,通过固定的参数,拿到 refresh_token(重点,后期全靠它)
{'grant_type':'authorization_code','code':'4/CpVOd8CljO_gxTRE1M5jtwEFwf8gRD44vrmKNDi4GSS.kr-GHuseD-oZEnp6UADFXm0E0MD3FlAI',//上一步获取的,'client_id':'816630003638-5p27m684jfpfa6sh6l9chbpreq2hg9ov.apps.googleusercontent.com','client_secret':'36WnPnojshgj56uhghj-xCo','redirect_uri':'https://api.whatsapp.sg/oauth2callback',
}
向以下的网址发起 Post 请求。
https://accounts.google.com/o/oauth2/token
一定要保证网络畅通,只有一次机会,返回的json对象如下
{
"access_token" : "",
"token_type" : "Bearer",
"expires_in" : 3600,
"refresh_token" : "1/zaaHNytlC3SEBX7F2cfrHcqJEa3KoAHYeXES6nmho"
}
refresh_token 就拿到了,注意一定要保存好,只有这一次机会,如果再次调用此接口 refresh_token 就是空了,不会返回了。
4.3、web-OAuth校验支付是否成功
拿到这个refresh_token就可以调用真正的校验接口了,例如我们后端调用的是否支付成功:
https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/products/{productId}/tokens/{purchaseToken}?access_token={$access_token}"
这里的packageName,productId,purchaseToken 大家都很熟悉了,就是Android 支付成功之后返回给我们的,直接传递给后端即可,而access_token其实就是我们上面拿到的 refresh_token。
我们需要拿到第一次返回的 refresh_token 保存起来,后续以刷新的方式来获取新的 refresh_token ,用于访问真正的API。
后台调用验证接口完成之后得到的对象如下:
{"kind": string,"purchaseTimeMillis": string,"purchaseState": integer,"consumptionState": integer,"developerPayload": string,"orderId": string,"purchaseType": integer,"acknowledgementState": integer,"purchaseToken": string,"productId": string,"quantity": integer,"obfuscatedExternalAccountId": string,"obfuscatedExternalProfileId": string,"regionCode": string
}
只需要验证状态即可:
consumptionState == 0 purchaseState == 0
说明这个商品已经购买了,并且也没有被消耗,那么此时就可以给移动端返回true,让移动端执行消耗操作。
后端PHP的校验谷歌内购是否成功示例代码:
public function checkGooglePay(){ $google_public_key = "你的公钥(google后台在你的应用下获取)"; $inapp_purchase_data = $_REQUEST['signtureTemp']; $inapp_data_signature = $_REQUEST['signtureDataTemp']; $key = "-----BEGIN PUBLIC KEY-----\n".chunk_split($google_public_key, 64,"\n").'-----END PUBLIC KEY-----'; $key = openssl_pkey_get_public($key); $signature = base64_decode($inapp_data_signature); $ok = openssl_verify($inapp_purchase_data,$signature,$key,OPENSSL_ALGO_SHA1); if (1 == $ok) { // 支付验证成功! //进行二次验证,订单查询 // 1.获取access_token(3600秒有效期)$access_token_url = "https://accounts.google.com/o/oauth2/token";$data_tmp2 = array('grant_type'=>'refresh_token','refresh_token'=>'',//长效token'client_id'=>'', //客户端id 'client_secret'=>'',//客户端密钥);$http = new http($access_token_url,'POST',5);$http->setContent($data_tmp2);$result = $http->exec();$result = json_decode($contents,true);$access_token = $result['access_token'];//2.通过获得access_token 就可以请求谷歌的API接口,获得订单状态$packageName=""//包名$productId="" //产品Id$purchaseToken=""$url = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/products/{productId}/tokens/{purchaseToken}?access_token={$access_token}";$http = new http($url,'GET',5);$http->setContent($data);$contents = $http->exec();$contents = json_decode($contents,true);if($contents['consumptionState'] == 0 && $contents['purchaseState'] == 0){//验证成功 购买成功并且没有消耗 google支付中客户端如果没有进行消耗是不能再次购买该商品//处理游戏逻辑 发钻石,通知客户端进行消耗}else{//订单验证失败} }else{ //签名验证失败} }
-
第一步是可选的,校验APK的签名,当前应用是不是谷歌市场下载的,如果不是从谷歌市场下载的那么支付不生效。如果你想要的校验APK来源就加上,不想校验也可以。
-
第二步就是开始校验谷歌内购支付订单的状态,拿到本地长期保存的refresh_token 以及之前获取到的client_id 和 client_secret 就可以到哪授权的 access_token 。
-
第三部就是拿到 access_token 以及 客户端传递的包名,产品id,支付凭证,调用校验接口,拿到订单的当前状态。
然后就是根据订单的状态判断返回给客户端是否有效,让客户端执行消耗操作。
如果您觉得有必要,也可以消耗之后再次调用接口校验,是否已购买,是否已消耗。
4.4、 创建Service Account的授权
其实之前的之前的 Web-OAuth 的方式来进行验证不是不行,但是步骤相对比较复杂,而更推荐的方式则是创建服务的方式来进行校验。
我们把视角拉回谷歌市场控制台,找到 Api Access 选项
其实我们在下面的访问权限就可以看到 Service Account 的选项。如果你已有 Service Account 就可以看到全部的关联的 Service Account 。如果你没有此服务,那么就可以点击创建服务去谷歌云创建。当我们到谷歌云里面点击创建 Service Account:
我们点击创建 Service Account 会走到创建服务的流程:
第一步随便写,关键是第二步:
选择角色为 Service Account Admin
第三步不填,直接提交:
你就能看到你创建的服务啦,接下来就是创建Key,Json的方式创建,然后下载到Json给到后台人员。
再下一步就回到谷歌商店控制台的 Api Access 看 Service Account 是否已经关联上了:
如果有这样的信息,说明关联上了,才是正确的流程,如果你创建了 Service Account,但是这里并没有展示,那么就肯定会错:
{ "code" : 401, "errors" : [ { "domain" : "androidpublisher", "message" : "The current user has insufficient permissions to perform the requested operation.", "reason" : "permissionDenied" } ], "message" : "The current user has insufficient permissions to perform the requested operation."
}
之后正常显示了服务,说明你的服务才能访问到谷歌市场这一边,接下来就是点击授予访问权限。
重点是要把财务信息的两项勾选上,这样才能访问到应用内支付校验的相关权限,如图所示:
点击保存修改之后就完成了,由于我们关联账号的时候已经勾选了 Google Play Android Developer API 权限,我们现在直接就能用了。
后端的用法各平台的使用方式不同,但是都是很简单的,直接集成谷歌的API,然后总共就两步,第一步设置Config属性把这个 Service Account 生成的Json文件传入,第二步直接调用 GoogleAPI 内置的校验方法即可,都是API内置了的更方便。
当我们客户端把packageName ,prodectId,purchaseToken 三个字段传给后端,他们直接调用 API 就能直接校验,相比 Web-OAuth 的方式要更简单一些。
校验结果如下:
OK,两种方法 Web-OAuth 的授权方式,以及 Service Account 的授权方式,两种都可以达到效果,用哪种都可以。
至此谷歌内购全部流程已结束。
丢单问题处理
使用
_inAppPurchase.purchaseStream是用来监听消息队列的回调的,也就是所有订单的状态以及信息回调,in_app_purchase这个属性的文档中这么说到:
IMPORTANT! You must subscribe to this stream as soon as your app launches,
preferably before returning your main App Widget in main(). Otherwise you
will miss purchase updated made before this stream is subscribed to.
重要!你必须在应用程序启动后立即订阅此流,
最好在main()中返回主应用程序小部件之前。
否则你将错过订阅此流之前更新的购买。
也就是说当我们的App在第一次启动的时候可以订阅此流来完成补单的操作,但是如果用户是之前丢单了,然后把App又卸载了,再次下载打开App后并没有进行登录操作,那用户的登录信息都拿不到怎么进行补单操作呢?
补单解决方案
让后端出一个补单的接口,在补单时只需要传一个订单号即可,那App都删除了,之前的订单号客户端怎么获取呢?使用flutter_keychain来实现,flutter_keychain就是使用的iOS的钥匙串来实现的,当用户在苹果服务器下单时,在钥匙串中保存后端生成的订单号,然后再商品成功发货后删除钥匙串里面的订单号,完成一个完整的购买过程,再购买时任何一环出了问题钥匙串里面缓存的订单号都不会被清空,这样在App下一次启动时,在首页或者main函数中使用_inAppPurchase.purchaseStream 监听,在拿到flutter_keychain中保存的订单号完成补单过程。
注意点
1.在完成苹果服务器付款流程后通知到自己服务器接口也就是验单的接口返回的是成功或者不成功都要调用_inAppPurchase.completePurchase(purchaseDetails)这个方法,不然下次就掉不起苹果支付来了,当然肯定会在失败的判断里面写明白让用户自己去走苹果退款流程的文案(概率较小,但是也得考虑)
2.商品类型如果是非消耗品的话,在下单完之后一定写一个按钮供点击调用复原的方法,要是不复原的话每次下的订单,订单号都是一样的(需要注意下)。
参考:
Flutter插件:
pay 2.0.0 :https://pub.dev/packages/pay
in_app_purchase 3.2.0:https://pub.dev/packages/in_app_purchase
Google pay:
https://developers.google.com/pay/api/android/overview?hl=zh-cn
https://developer.android.com/google/play/billing
Apple pay:
https://developer.apple.com/documentation/passkit_apple_pay_and_wallet/apple_pay/setting_up_apple_pay
https://developer.apple.com/in-app-purchase/
教程:
https://juejin.cn/post/7290009513470623800
https://juejin.cn/post/7020651416276434958
https://blog.csdn.net/mumubumaopao/article/details/136112183
https://juejin.cn/post/7233310081809760317