为你的应用集成谷歌结算(客户端集成+服务端校验)

最近在开发自己的一款新产品 言叶,上架到谷歌商店之后有国外用户反馈想要使用谷歌支付,于是我准备为自己的应用集成谷歌结算以允许用户进行内购付款。

之前我完全没用集成过谷歌支付,从寻找到的一些资料来看,谷歌结算的开发方式已经做了调整,新的开发方式比之前使用 AIDL 开发要简单一些。

1、客户端集成

1.1 文档资料

对于客户端集成,官方文档已经给了比较详尽的说明,这里对应的文档资料提供一下:

虽然文档集成说明很详细但是有时候我们需要知道具体的某些类及其字段的含义,类文档可以参考:

1.2 集成的一些细节

既然谷歌官方文档介绍的已经比较详细了,这里我只对自己集成的时候一些细节设计介绍一下。

1. 依赖隔离

记得之前集成了购买的功能的话,安装 APK 的时候会提示你 “需要谷歌服务才能使用”,所以我在开发的时候提前做了准备。我希望的是,只在国外版本加入 Gooogle billing 依赖,可以在打包的时候进行配置,而国内版本不加入谷歌结算的依赖。因此,在应用 module 之外,我们需要加一个为谷歌支付使用的模块。此模块只用来处理谷歌结算业务,其依赖如下:

dependencies {
    // billing
    compileOnly "com.android.billingclient:billing:3.0.0"
    compileOnly "com.android.billingclient:billing-ktx:3.0.0"
    // ...
}

这里我使用了 compileOnly 进行依赖,这样是否打入谷歌结算库只要在主工程模块进行配置即可。另外,在代码中通过反射获取类的方式来判断是否加入了谷歌结算依赖:

private fun isGoogleDependencyAdded(): Boolean = try {
    Class.forName("com.android.billingclient.api.BillingClient")
    true
} catch (e: ClassNotFoundException) {
    false
}

此外,我们需要保证在主工程中不直接引用谷歌结算库的类。因此,我们需要对谷歌结算库的类进行包装,

data class SkuDetailsWrapper(val skuDetails: SkuDetails) {

    /** Get the product id */
    fun getProductId(): String = skuDetails.sku

    /** Get sku price */
    fun getPrice(): String = skuDetails.price

    /** Launch billing flow */
    fun launch(activity: Activity, onSuccess: () -> Unit, onFailed: (msg: String) -> Unit) {
        BillingManager.instance.launchBillingFlow(activity, skuDetails, onSuccess, onFailed)
    }
}

/** Purchase wrapper */
data class PurchaseWrapper(val purchase: Purchase) {

    fun getPackageName(): String = purchase.packageName

    fun getPurchaseToken(): String = purchase.purchaseToken

    fun isAcknowledged(): Boolean = purchase.isAcknowledged

    fun getOrderId(): String = purchase.orderId

    /** Get google goods id */
    fun getGoogleGoodsId(): String = purchase.sku

    /** Is given product was purchased */
    fun isPurchased(): Boolean = purchase.purchaseState == Purchase.PurchaseState.PURCHASED
}

/** Billing result wrapper */
data class BillingResultWrapper(val billingResult: BillingResult)

这样我们只需要对外暴露自己的包装类即可。打包的时候可以自由决定是否加入谷歌结算的依赖。

2. 重连逻辑

谷歌结算服务在再次使用的时候需要重新连接,并且也无法保证每次连接一定成功,因此需要重试,并且我们可以在每次请求谷歌服务的时候都先进行连接。此外,谷歌结算库又不允许多次进行连接,因此又需要做一些控制防止请求过多。

/** Play service connection callback */
interface OnConnectedListener {
    fun onConnected()
}

class BillingManager private constructor() {
    private val connectListeners = mutableListOf<OnConnectedListener>()

    private var connect = AtomicInteger(STATE_DISCONNECTED)

    /** Connect Google billing with retry logic. */
    private fun connect(onConnected: () -> Unit, onFailed: (msg: String) -> Unit) {
        // batch the callbacks to avoid too much connections
        connectListeners.add(object : OnConnectedListener {
            override fun onConnected() {
                onConnected()
            }
        })
        if (connect.get() == STATE_CONNECTED) {
            connectListeners.forEach { it.onConnected() }
            connectListeners.clear()
        } else {
            if (connect.get() == STATE_DISCONNECTED) {
                connect.set(STATE_CONNECTING)
                doConnect({
                    connectListeners.forEach { it.onConnected() }
                    connectListeners.clear()
                    // force to reconnect service next time
                    connect.set(STATE_DISCONNECTED)
                }, onFailed)
            }
        }
    }

    /** Do real connection. */
    private fun doConnect(onConnected: () -> Unit, onFailed: (msg: String) -> Unit) {
        billingClient.startConnection(object : BillingClientStateListener {
            override fun onBillingServiceDisconnected() {
                L.d("onBillingServiceDisconnected")
                connect.set(STATE_DISCONNECTED)
            }

            override fun onBillingSetupFinished(p0: BillingResult) {
                L.d("Setup finished: ${p0.responseCode} ${p0.debugMessage}")
                if (p0.responseCode == BillingClient.BillingResponseCode.OK) {
                    onConnected()
                } else {
                    L.d("Setup finished with failure: ${p0.responseCode} ${p0.debugMessage}.")
                    onFailed("Failed: ${p0.responseCode} ${p0.debugMessage}")
                }
            }
        })
    }
}

上面我们通过 AtomInteger 来标记当前的状态,并且如果当前正在连接,会将请求通过 connectListeners 收集起来,连接成功之后再全部触发。这样,当我们需要进行请求的时候只需要下面这样调用即可:

    fun queryInAppPurchase(onSuccess: () -> Unit = {}, onFailed: (msg: String) -> Unit = {}) {
        connect({
            GlobalScope.launch(Dispatchers.Main) {
                val purchaseResult = withContext(Dispatchers.IO) {
                    billingClient.queryPurchases(BillingClient.SkuType.INAPP)
                }
                // ... do one get result
            }
        }, onFailed)
    }

按照上述配置,查询产品之后调用 launchBillingFlow 即可触发支付对话框。然后,将应用上传到 Google Play Console 并在配置测试人员之后即可测试购买。这里有几个点需要注意下,不然可能会导致一些丢单和安全性问题,对于整体的购买流程设计放在服务端校验部分一起说明。

1.3 遇到的一些问题

1. 报错:Google Play In-app Billing API version is less than 3

这个错误比较奇葩,参考 《Google Play In-app Billing API version is less than 3》 这篇文章,是谷歌账号的问题。我在测试的时候也遇到了这个问题。总结下来就是:当你的账号的区域被识别成了国内的时候就会出现这个报错。所以,无奈只能新注册了几个谷歌账号,然后将位置设置在了国外。

2、服务端校验

需要注意的是,用户完成了付款之后需要你在代码中确认购买才算支付完成,不然走退款流程。这里有些设计逻辑需要注意下,因为一方面你要防止黑客伪装谷歌的响应;另一方面,你应该避免丢单的问题。我们完全可以把客户端的逻辑认为是不可靠的,因为你无法保证用户按照你期望的流程走下去,可能意外地节点一个退出就导致流程失败,所以这里的设计逻辑值得花费时间琢磨一下。

对于服务端校验,谷歌有提供 Java 和 Python 版本的三方库。但是,我试用了下,不太好用。这里的一些校验逻辑完全可以通过纯 http 请求来实现,不需要三方库一样可以使用。服务端校验方面的文档和文章确实比较少,后台配置也比较麻烦,特别是当你第一次配置的时候。

2.1 产品校验接口使用

1. 平台配置

支付校验方面,谷歌提供了一个用来查询产品信息的接口。当客户端完成购买之后会获取到一个 purchaseToken,我们可以使用该 purchaseToken 请求谷歌的接口来判断该订单是否有效(黑客可能会伪造谷歌的返回结果)。

这个接口和配置很麻烦,文档也很少。

对于配置,参考 Google Play Developer API 使用入门,链接:https://developers.google.cn/android-publisher/getting_started

简单来说,你需要

  • 现在 Google Play Console 的 API 访问权限 中关联 Google Cloud 项目
  • 然后到 Google APIs 中创建 OAuth 2.0 客户端 ID 凭据
  • 客户端类型可以有多种,这里选择 Web 应用
  • 创建 Web 应用完毕之后你将获取到 client_id 和 redirect_uri 等信息,这里的 redirect_uri 是你自己指定的一个链接,任何地址都可以,它的作用就是来做一个跳转。跳转的地址上面会附带一个 code 参数,这里的目的就是为了获取这个参数。

2. 应用授权:获取 code 参数

应用授权部分请求参考文档:https://developers.google.com/android-publisher/authorization

将上述创建 OAuth 客户端的信息拼接成一个 url,模板如下

https://accounts.google.com/o/oauth2/auth?scope=https://www.googleapis.com/auth/androidpublisher&response_type=code&access_type=offline&redirect_uri=...&client_id=...

然后访问这个链接。你将像上述所说一样跳转到你指定的 redirect_uri,此时从跳转之后的地址上面可以获取到 code 参数。这里的 code 就像一个钥匙,我们需要使用它来获取 refresh_token 和 access_token. 这里有些坑,我们稍后说明。

3. 获取 refresh_token 和 access_token

查询产品信息的时候我们需要使用 access_token,但是 access_token 会过期,大概时间是 1 个小时。当过期之后我们需要使用 refresh token 访问谷歌的链接来获取新的 access token. 服务端的 http 请求,我仍然使用 OkHTtp+Retrofit 来完成。Retorfit 接口如下:

// base url: https://accounts.google.com/o/oauth2/
public interface GooglePlayOAuthApi {

    @FormUrlEncoded
    @POST("token")
    Call<GoogleOAuthResponse> oauth(@Field("grant_type") String grantType,
                                    @Field("code") String code,
                                    @Field("client_id") String clientId,
                                    @Field("client_secret") String clientSecret,
                                    @Field("redirect_uri") String redirectUri);

    @FormUrlEncoded
    @POST("token")
    Call<GoogleOAuthResponse> refreshToken(@Field("grant_type") String grantType,
                                           @Field("refresh_token") String refreshToken,
                                           @Field("client_id") String clientId,
                                           @Field("client_secret") String clientSecret);

}

响应数据结构如下:

@Data
public class GoogleOAuthResponse {
    private String access_token;
    private String token_type;
    private int expires_in;
    private String refresh_token;
}

这是两个 form 请求,从上到下,分别用来获取第一次获取和后续更新 access token 和 refresh token.

4. access token 更新设计

不论是第一次请求还是后续更新 access token 和 refresh token 的时候,我们可以将获取到的结果存储到 Redis 中:

private void saveGoogleOAuthResponse(GoogleOAuthResponse response) {
    // the refresh token might be null if the trying to refresh token
    if (response.getRefresh_token() != null) {
        redisHelper.setGoogleRefreshToken(response.getRefresh_token());
    }
    redisHelper.setGoogleAccessToken(response.getAccess_token());
    redisHelper.setGoogleAccessTokenRefreshTime((response.getExpires_in()-60)*1000+System.currentTimeMillis());
}

这里的 expires_in 单位是秒,我也直接计算了下个过期的时间点。我会让过期的前一分钟请求产品信息之前做 refresh token 的请求。

5. 获取产品信息

获取产品信息使用接口如下,类文档地址:https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products#ProductPurchase

// base url: https://androidpublisher.googleapis.com/
public interface GooglePlayPaymentApi {

    @GET("/androidpublisher/v3/applications/{packageName}/purchases/products/{productId}/tokens/{token}")
    Call<GoogleProductResponse> getProduct(@Path("packageName") String packageName,
                                           @Path("productId") String productId,
                                           @Path("token") String token,
                                           @Query("access_token") String accessToken);

    @GET("/androidpublisher/v2/applications/{packageName}/purchases/subscriptions/{subscriptionId}/tokens/{token}")
    Call<GoogleSubResponse> getSubscription(@Path("packageName") String packageName,
                                            @Path("subscriptionId") String subscriptionId,
                                            @Path("token") String token,
                                            @Query("access_token") String accessToken);
}

这两个接口分别用来获取产品的信息和订阅的信息。

2.2 整体购买流程设计

如上所述,客户端的逻辑是不安全、不可靠的。在我的应用中,整个谷歌支付流程如下:

  • 完成谷歌付款,回调,获取到 purchaseToken
  • 访问后端校验接口,这里的校验接口会同时将订单信息记录下来,然后返回校验结果
  • 若服务端校验成功,客户端调用谷歌结算库的 consumeAsync()acknowledgePurchase() 等即可确认购买,此时用户完成付款
  • 若确认购买成功,调用后端完成支付接口,后端再次进行订单信息校验,校验成功则进行授权
  • 此外,最重要的服务端开启定时任务,按照指定的时间间隔扫描未完成的谷歌订单,再次从谷歌拉取订单信息,若已完成支付而未授权则进行授权逻辑,若已走退款逻辑,则结束整个订单流程。这里主要是解决确认购买了,确认成功的响应没用收到的问题,没用调用到后端完成支付接口导致的丢单问题。

这里需要注意下,我们在确认收款之前会先校验并记录订单信息。当然,你也可以直接确认,然后每次使用谷歌结算库的 queryPurchases() 方法查询订单历史来解决丢失问题。这样可以减少一次对谷歌的请求(谷歌日请求有上限)。此外,还需要注意下在进行校验的时候记录完整的订单信息(包名、产品id、purchaseToken),这样定时任务才能再次访问谷歌的订单接口。

最后,部署。谷歌的接口需要海外服务器才能正常使用,并且海外服务器相比于国内服务器价格略高。对于我的应用,考虑到项目初期,因此只有国外的谷歌订单请求走海外服务器,其他请求还是走国内服务器。数据库和 Redis 还是存储在之前的服务器上面,这样数据库管理容易得多,并且不需要重新搭建数据库和 Redis 环境,一台轻量型服务器基本可以满足要求了。

2.3 一点坑

1. 请求不返回 refresh token 的问题

需要注意的是,如果想要得到 refresh token,需要我们在拼接链接的时候指定 access_type 参数为 offline,并且只有在第一次授权的时候返回 refresh token,后面的时候不会返回了。参考:https://stackoverflow.com/questions/10827920/not-receiving-google-oauth-refresh-token

3、总结

谷歌结算集成国内资料比较少,我还是花费了我挺多时间的,这里记录下整个开发和设计流程,以做备份兼供后来者参考。

对于 言叶,这是我新开发的笔记软件,支持 Markdown 语法,笔记采用文件目录结构,可以通过 WebDAV 在手机和电脑之间做笔记同步,内部对文件结构做了整理,无需任何改动可以在双端进行编辑和浏览,并且支持 Hexo 的便笺和标签管理方式,非常适合程序员使用。

感谢阅读!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容