[Android & PHP] 伺服器端 IAP(IAB) 內購通知實作 Real-time Developer Notifications (RTDN)

因為工作需要,研究了這塊,
因為文件有點不連續,又蠻深入的,
左思右想,還是分享出來,給有需要串接 App 內購的朋友,
App 內購在 Android 叫做 In-app Billing (IAB) 在蘋果 iOS 這邊叫做 In-app purchase (IAP),
講得就是一個東西,App 需要購買虛擬商品的時候,必須透過 GooglePlay (Android 平台) 或者蘋果 Apple (iOS 平台) 的金流。

為什麼會需要它?

這邊專注講 Android 平台的 In-app Billing (IAB),

要提到一下 App 內購的流程:

  1. 由使用者按下購買鈕,你的 Android 程式呼叫系統購買頁面
  2. 使用者在系統購買頁面做扣款事宜
    (如果有設定好的話,可能是指紋或者刷臉再次確認,如果第一次使用就會要求你做登入 Google 帳號,輸入信用卡等等的步驟再跳回來)
  3. 你的 Android 程式收到購買狀態(成功/失敗/使用者取消) 並回傳 purchaseToken
  4. 如果購買成功,你的 Android 收到 purchaseToken 之後要 回傳給你的後端伺服器做驗證啟用對應功能給使用者

最後這一步才是關鍵,後端伺服器確認了之後才會開啟對應功能給使用者嘛。
(App 無伺服器的流程是另外一種,而且漏洞很多,先姑且不討論。)

正常流程大致是這樣,但總有一些些例外。

例如:

  • (訂閱型商品常見)每月時間到了系統自動做扣款動作
  • 使用者是在 GooglePlay 商店按的購買而不是在你 App 裡面按
  • Google 那邊刷使用者的卡刷失敗了

等等諸如此類,它並不是發生在你的 App 中,所以你的 App 沒辦法知道那時候訂的商品狀態更新了。

怎麼辦呢?

以前的傳統做法:在 你的 App 啟動時 或者 喚醒 的時候,向 Google 再次確認一次有沒有未處理的購買訂單。

如果訂單不同步怎麼辦?請使用者再次開一次 App 吧!訂單就同步了。

如果你只有一個 Android 平台,
這沒問題,使用者的所有流程都是在你的 App 下進行的,
但如今你的服務可能會有 iOS 平台、 Android 平台、網站…等等的平台。
如果使用者買了之後沒有開 App ,很容易會造成訂單不一致問題。
這個時候就需要內購伺服器通知了。

總結來說,這個 App 內購伺服器通知就是為了解決訂單不同步的問題,
透過伺服器與伺服器對傳通知確認,即時補齊 App 外交易的訂單。

這個 App 內購伺服器通知叫做 Real-time Developer Notifications (RTDN)。

怎麼做?

在使用這個之前,必須先知道 Google 出的 Pub/Sub 模組。
他會透過 Pub/Sub 模組發通知出來,我們可以用 Push (常用) 或者 Pull 方式實作。

Pub/Sub 模組

Pub/Sub 模組它是一個類似廣播模組,如果有需要的話,就設定註冊它,你就會收到來自程式的廣播通知。
一個頻道稱為 Topic
一個訂閱人叫做 Subscription

其中 Subscription 分為 Pull 跟 Push 二種。

  • Pull: 為一個 Queue (佇列) ,幫你 Queue 住所有的訊息直到你打 API 去拉它,
  • Push: 要指定一個 API endpoint 給它,網址就是填你的伺服器 API 位址,他會用文件定義的 API 格式,主動去呼叫你的伺服器。
    (這部分就跟 Apple 概念接近了,這也是最常用的方式。)

官方文件說,一個 app 要獨立一個 Google Cloud project 這點要注意。
在有限額度下,多開 Google Cloud project 帳號應該也無仿。

操作步驟

等等的步驟大致是,
建立一個 Topic 與其對應的 Subscription,然後 Subscription 設定好連接你的伺服器,
設定 app 變更通知要發送到哪一個 Topic。
設定有點小複雜,可能要慢慢研究一下。

到 Google Cloud Console 新增一個 project
(如果有的話就選擇你的 project,假設叫做 my_app_project

Step1. 建立 Topic

到 BigData > Pub/Sub 的頁籤中:
https://console.cloud.google.com/cloudpubsub/topic/list

先建立一個 Topic,
名字自訂,假設叫 my_app_topic

有勾 Add a default subscription 的話,它會自動幫你建立一個 Pull 的 Subscription 叫做 my_app_topic-sub
我們等等會使用 Push 的 Subscription,這個 my_app_topic-sub 的 Pull Subscription 可以先留著,
到時候可以直接在 Cloud Console 這邊直接驗證。

Step2. 測試發送訊息到 Topic

可以先試試看 Topic 發通知跟接收通知的感覺,
在 Pub/Sub > Topic 頁籤中,

點開剛剛建立的 my_app_topic
在 MESSAGES 頁籤,有一個 PUBLISH MESSAGE 按鈕可以按,

按下後看到發送測試訊息的畫面,可以在 Message body 的地方隨意打點字上去,
然後按 publish 發送。

在 Pub/Sub > Subscription 頁籤中,點開自動建立的 my_app_topic-sub

點開 MESSAGES 頁籤,看到列表是空白是正常的,

按下 PULL 更新通知,剛剛發送的訊息就會出現,如果沒有出現,可能要稍等個 1-2分鐘 再按一次(因為它發送跟接收是非同步的)。

Step3. 通知連接你的 app

這邊就是它文件寫的最奇妙的地方了,

在 Pub/Sub > Topic 的頁籤列表中,按下三顆點點 > View premissions 按鈕,

按下 ADD PRINCIPAL 按鈕,

在 New principals 的地方手動輸入 [email protected]
Select a role 的地方手動選擇 Pub/Sub Publisher ,
(這個值是 Google 指定的 Service account 名字,寫在文件裡的,大家都一樣)

然後進入 Google play console,
https://play.google.com/console
並選擇你的 app。

在 Monetization setup 的頁面中,
Real-time developer notifications 區域,
Topic name 的地方, 按照格式 打入剛剛的 Topic 名字:

projects/my_app_project/topics/my_app_topic

(這邊要依照你實際情況來打,每個人不太一樣)

按下 Save changes 然後按下 Send test notification 試試。

Step4. 測試通知發送連接情況

在剛剛 Real-time developer notifications 區域,
按下 Send test notification 試試,
流程跟單獨測試 Topic 類似,
回到剛剛 Pub/Sub > Topic 頁籤中,
在 Pub/Sub > Subscription 頁籤中,點開剛剛的 my_app_topic-sub
按下 PULL 更新通知,會出現測試類似以下的訊息,如果沒有出現,可能要稍等個 1-2分鐘 再按一次(因為它發送跟接收是非同步的)

{"version":"1.0","packageName":"com.myawesome.app","eventTimeMillis":"1638346194077","testNotification":{"version":"1.0"}}

實作伺服器接收端 (PHP)

接收端分成二個方式 Pull 跟 Push 的方式,二個擇一實作即可。
個人比較推薦使用 Push 的方式。

Pull 的方式

以下是 PHP 實作,
如果你不是是用這語言開發,可以參考他其他的 Client library 來實作
https://cloud.google.com/pubsub/docs/reference/libraries#cloud-console

PHP 做法就是使用 composer 引用 cloud-pubsubgoogle/apiclient 套件。

$ composer require google/apiclient
$ composer require google/cloud-pubsub

或者手動在 composer.jsonrequire 區域加上關聯

{
  "config": {
    // ....
  },
  "require": {
    "google/apiclient": "^2.7"
    "google/cloud-pubsub": "^1.34"
  }
}

再執行

$ composer update

你可能需要建立一個 Service account 並給予 Pub/Sub Subscriber 權限
在 Key 的地方按下 Add Key 選擇 json 得到 credentials.json

credentials.json 文件大概長這樣:

{
  "type": "service_account",
  "project_id": "my_app_project",
  "private_key_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBA.......N4M8zLc\n-----END PRIVATE KEY-----\n",
  "client_email": "my-service-account@my_app_project.iam.gserviceaccount.com",
  "client_id": "xxxxxxxxxxxxxxxxxxxxx",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/my-service-account%40my_app_project.iam.gserviceaccount.com"
}

credentials.json 放到伺服器能存取到的地方,假設是放在跟 PHP 相同的目錄
就可以新增一個 PubSubClient 物件來抓取 Pull 的通知

<?php
require __DIR__ . '/vendor/autoload.php';

use Google\Cloud\PubSub\PubSubClient;

$pubSub = new PubSubClient([
    'keyFilePath' => __DIR__ . '/credentials.json',
    'projectId' => 'my_app_project'
]);

$subscription = $pubSub->subscription('my_app_topic-sub');

$messages = $subscription->pull();

foreach ($messages as $message) {
    echo $message->data();
}

這個範例就能跟網頁介面一樣,一次拉一些一次拉一些,拉到你想要的訊息,
可能用一個 cronjob 排程讓它固定跑,就可以使用它。

你就可以得到類似這樣的一筆資料:

{
  "version": "1.0",
  "packageName": "com.myawesome.app",
  "eventTimeMillis": "1638375316338",
  "subscriptionNotification": {
    "version": "1.0",
    "notificationType": 13,
    "purchaseToken": "df................CnPIQ",
    "subscriptionId": "com.myawesome.app.one_month_subscription"
  }
}

這個是一個訂閱通知的範例,
根據查表 notificationType = 13 是 SUBSCRIPTION_EXPIRED 也就是訂閱過期了。

根據文件,除了 subscriptionNotification(訂閱通知)之外,它還有 oneTimeProductNotification(單次購賣通知)
細節就不多講了,格式很接近

{
  "version":"1.0",
  "packageName":"com.myawesome.app",
  "eventTimeMillis":"1638375316338",
  "oneTimeProductNotification":
  {
    "version":"1.0",
    "notificationType":1,
    "purchaseToken":"fg................HBbID",
    "sku":"com.myawesome.app.coin"
  }
}

單次購買的 notificationType 與訂閱的 notificationType 定義不同,需注意。

Push 的方式(常用)

你要設計一個 API endpoint 給他,每當訂單狀態有變更時,Google 伺服器它會用 POST 把資料回傳給你

這邊分成二部分,有認證跟沒認證,先解釋乾淨版不需認證的方式。
(這邊的認證是指認證是否真的由 Google 伺服器來的通知,而不是別人假冒的)

格式參考如下:
https://developer.android.com/google/play/billing/rtdn-reference

接收的文件路徑:
https://cloud.google.com/pubsub/docs/push

你會收到來自 Google 伺服器經由 POST 打來的資料,類似這樣:

{
  "message": {
    "attributes": {
      "key": "value"
    },
    "data": "eyAidmVx................GlvbiB9",
    "messageId": "136969346945"
  },
  "subscription": "projects/my_app_project/subscriptions/mysubscription"
}

其中這個 data 欄位是一個 base64 編碼後的字串,解碼 data 欄位可以得到類似以下的資料:

{
  "version": "1.0",
  "packageName": "com.myawesome.app",
  "eventTimeMillis": "1638375316338",
  "subscriptionNotification": {
    "version": "1.0",
    "notificationType": 13,
    "purchaseToken": "df................CnPIQ",
    "subscriptionId": "com.myawesome.app.one_month_subscription"
  }
}

這個是一個訂閱通知的範例,
根據查表 notificationType = 13 是 SUBSCRIPTION_EXPIRED 也就是訂閱過期了

根據文件,除了 subscriptionNotification(訂閱通知)之外,它還有 oneTimeProductNotification(單次購賣通知)
細節就不多講了,格式很接近

{
  "version":"1.0",
  "packageName":"com.myawesome.app",
  "eventTimeMillis":"1638375316338",
  "oneTimeProductNotification":
  {
    "version":"1.0",
    "notificationType":1,
    "purchaseToken":"fg................HBbID",
    "sku":"com.myawesome.app.coin"
  }
}

單次購買的 notificationType 與訂閱的 notificationType 定義不同,需注意。

查找訂單細節(訂閱型資料)

伺服器訂購通知只給你很粗糙的內容,實際內容還需要你另外打 API 達成。

PHP 需 composer 引用 apiclient 套件

$ composer require google/apiclient

或者手動在 composer.jsonrequire 區域加上關聯

{
  "config": {
    // ....
  },
  "require": {
    "google/apiclient": "^2.7"
  }
}

再執行

$ composer update

以下程式碼是一個抓取訂閱訂單的範例:
需要填入 $packageName, $subscriptionId$purchaseToken

$packageName = 'com.myawesome.app';
$subscriptionId = 'com.myawesome.app.one_month_subscription';
$purchaseToken = 'df................CnPIQ';

$client = new \Google_Client();
try {
    $client->setAuthConfig(dirname(__FILE__) . '/credentials.json');
} catch (\Google\Exception $e) {
    throw $e;
}
$client->addScope('https://www.googleapis.com/auth/androidpublisher');
$service = new \Google_Service_AndroidPublisher($client);
$purchase = $service->purchases_subscriptions->get($packageName, $subscriptionId, $purchaseToken);

var_dump($purchase);

大致的資料格式如下(引用官方文件):

{
  "kind": string,
  "startTimeMillis": string,
  "expiryTimeMillis": string,
  "autoResumeTimeMillis": string,
  "autoRenewing": boolean,
  "priceCurrencyCode": string,
  "priceAmountMicros": string,
  "introductoryPriceInfo": {
    object (IntroductoryPriceInfo)
  },
  "countryCode": string,
  "developerPayload": string,
  "paymentState": integer,
  "cancelReason": integer,
  "userCancellationTimeMillis": string,
  "cancelSurveyResult": {
    object (SubscriptionCancelSurveyResult)
  },
  "orderId": string,
  "linkedPurchaseToken": string,
  "purchaseType": integer,
  "priceChange": {
    object (SubscriptionPriceChange)
  },
  "profileName": string,
  "emailAddress": string,
  "givenName": string,
  "familyName": string,
  "profileId": string,
  "acknowledgementState": integer,
  "externalAccountId": string,
  "promotionType": integer,
  "promotionCode": string,
  "obfuscatedExternalAccountId": string,
  "obfuscatedExternalProfileId": string
}

REST 文件在這:
https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptions
PHP 的資料格式參考(很難看懂,還是參照 REST 文件吧!):
https://developers.google.com/resources/api-libraries/documentation/androidpublisher/v3/php/latest/class-Google_Service_AndroidPublisher_SubscriptionPurchase.html

基本上對應的資料格式會有對應的 Method 可以呼叫。

查找訂單細節(單次購買型資料)

伺服器訂購通知只給你很粗糙的內容,實際內容還需要你另外打 API 達成。

PHP 需 composer 引用 apiclient 套件

$ composer require google/apiclient

或者手動在 composer.jsonrequire 區域加上關聯

{
  "config": {
    // ....
  },
  "require": {
    "google/apiclient": "^2.7"
  }
}

再執行

$ composer update

以下程式碼是一個抓取訂閱訂單的範例:
需要填入 $packageName, $sku$purchaseToken

$packageName = 'com.myawesome.app';
$sku = 'com.myawesome.app.coin';
$purchaseToken = 'fg................HBbID';

$client = new \Google_Client();
try {
    $client->setAuthConfig(dirname(__FILE__) . '/credentials.json');
} catch (\Google\Exception $e) {
    throw $e;
}
$client->addScope('https://www.googleapis.com/auth/androidpublisher');
$service = new \Google_Service_AndroidPublisher($client);
$purchase = $service->purchases_products->get($packageName, $sku, $purchaseToken);

var_dump($purchase);

大致的資料格式如下(引用官方文件):

{
  "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
}

REST 文件在這:
https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products
PHP 的資料格式參考(很難看懂,還是參照 REST 文件吧!):
https://developers.google.com/resources/api-libraries/documentation/androidpublisher/v3/php/latest/class-Google_Service_AndroidPublisher_ProductPurchase.html

基本上對應的資料格式會有對應的 Method 可以呼叫。

回應 (Acknowledge) 訂單已處理(訂閱型)

有一個很重要的就是,伺服器處理完訂單要打 API 跟 Google 伺服器講:商品已送達。
這個行為稱為 Acknowledge

不然它會認為沒有開對應功能給使用者,計時三天後,自動做退費,所以這功能很重要。

$packageName = 'com.myawesome.app';
$gpaOrderId = 'GPA.xxxx-xxxx-xxxx-xxxxx';
$purchaseToken = 'df................CnPIQ';

try {
  $client = new \Google_Client();
  $client->setAuthConfig(dirname(__FILE__) . '/credentials.json');
  $client->addScope('https://www.googleapis.com/auth/androidpublisher'); 
  $service = new \Google_Service_AndroidPublisher($client);

  $postBody = new Google_Service_AndroidPublisher_SubscriptionPurchasesAcknowledgeRequest();
  $postBody->setDeveloperPayload("");
  $response = $service->purchases_subscriptions->acknowledge($packageName, $gpaOrderId, $purchaseToken, $postBody);

  var_dump($response);
} catch (\Google\Exception $e) {
    throw $e;
}

以上是一個簡單範例。

回應 (Acknowledge) 訂單已處理(單次購買型)

伺服器處理完訂單要打 API 跟 Google 伺服器講:商品已送達。
這個行為稱為 Acknowledge

不然它會認為沒有開對應功能給使用者,計時三天後,自動做退費,所以這功能很重要。
原理類似,就不贅述了。

$packageName = 'com.myawesome.app';
$sku = 'com.myawesome.app.coin';
$purchaseToken = 'fg................HBbID';

try {
  $client = new \Google_Client();
  $client->setAuthConfig(dirname(__FILE__) . '/credentials.json');
  $client->addScope('https://www.googleapis.com/auth/androidpublisher'); 
  $service = new \Google_Service_AndroidPublisher($client);

  $postBody = new Google_Service_AndroidPublisher_ProductPurchasesAcknowledgeRequest();
  $postBody->setDeveloperPayload("");
  $response = $service->purchases_products->acknowledge($packageName, $sku, $purchaseToken, $postBody);

  var_dump($response);
} catch (\Google\Exception $e) {
    throw $e;
}

以上就是各種實作細節,細節很多,文件很長,可能要多看幾次。
祝串接愉快。

參考資料