kawabatas技術ブログ

試してみたことを書いていきます

Golang で iOS/Android アプリ内課金の実装

概要

アプリ内課金の実装に関わったので、メモ。

※アプリ内課金はプラットフォーム側の影響を受ける。記載内容が古くなっている可能性があるので注意。

テーブル設計

f:id:kawabatas:20190519171953p:plain

  • products テーブル

    • App Store, Google Play に登録するアプリ内課金アイテムの情報。product_id には "com.example.100gold" などの文字列が入る。
  • receipts テーブル

    • クライアントから送られたレシートをそのまま保存する
  • purchases テーブル

    • tranasction_id カラムは App Store なら Transaction ID を入れ、Google Play なら Order ID を入れる。
  • paid_golds テーブル

    • 有償ゴールドの数
  • gold_histories テーブル

    • 有償ゴールドの購入、使用の履歴

レシートの検証

こちらを使わせていただいた。

github.com

AWA で使用されているとのことで、信頼できるし、ありがたい...

自動購読課金について【iOS編】自動購読課金について【Android編】で課金処理の流れも非常に参考になった。ありがたい...

実装したコード抜粋

// クライアントから送られたレシートの検証後のデータを扱うモデル
package model

type PurchaseOrder struct {
    Payload    string
    Store      StoreType // AppStore, GooglePlay
    OrderLines []*OrderLine
}

type OrderLine struct {
    TransactionID         string
    OriginalTransactionID string
    Quantity              int
    ProductID             string
}

AppStoreValidator

package validator

import (
    "context"
    "github.com/awa/go-iap/appstore"
)

type AppStoreValidator struct {
    Client *appstore.Client
}

// NewAppStoreValidator generates a validator.
func NewAppStoreValidator() *AppStoreValidator {
    return &AppStoreValidator{
        Client: appstore.New(),
    }
}

// Verify sends receipts and gets order lines.
func (v *AppStoreValidator) Verify(ctx context.Context, payload string) ([]*model.OrderLine, error) {
    req := appstore.IAPRequest{
        ReceiptData: payload,
    }
    resp := &appstore.IAPResponse{}
    err := v.Client.Verify(ctx, req, resp)
    if err != nil {
        return nil, err
    }

    if err := handleError(resp.Status); err != nil {
        return nil, err
    }

    var orderLines []*model.OrderLine
    for _, iap := range resp.Receipt.InApp {
        // 略
        orderLines = append(orderLines, order)
    }

    return orderLines, nil
}

// 成功したら 20x 系、レシートの不備・不正等は 40x 系、サーバエラー時 (クライアントにレシート送信をリトライしてほしい) は 50x 系としたかったので、独自で定義
func handleError(status int) error {
    err := appstore.HandleError(status)
    if err == nil {
        return nil
    }

    switch status {
    case 21000, 21002, 21003, 21004, 21010:
        // レシートが不正
        return domain.ErrInvalidReceipt
    case 21005, 21009:
        // サーバ側のエラーなので再送信を促す
        // 21009 はたいていの場合は App Store 側の問題であり、リトライを促す旨指示がある
        // https://developer.apple.com/library/content/technotes/tn2413/_index.html#//apple_ref/doc/uid/DTS40016228-CH1-RECEIPT-_WHEN_VALIDATING_MY_RECEIPT__THE_APP_STORE_RETURNS_A_STATUS_CODE_OF_21009__
        return domain.ErrReceiptValidationFailed
    }
    if status >= 21100 && status <= 21199 {
        return domain.ErrReceiptValidationFailed
    }
    return err
}

GooglePlayValidator

package validator

import (
    "context"
    "encoding/base64"
    "encoding/json"
    "github.com/awa/go-iap/playstore"
)

type GooglePlayValidator struct {
    Key             string
    VerifySignature func(base64EncodedPublicKey string, receipt []byte, signature string) (bool, error)
}

type IABPayload struct {
    Json      string `json:"json"`
    Signature string `json:"signature"`
}

type IABReceipt struct {
    OrderID          string `json:"orderId"`
    PackageName      string `json:"packageName"`
    ProductID        string `json:"productId"`
    PurchaseTime     int64  `json:"purchaseTime"`
    PurchaseState    int    `json:"purchaseState"`
    DeveloperPayload string `json:"developerPayload"`
    PurchaseToken    string `json:"purchaseToken"`
    AutoRenewing     bool   `json:"autoRenewing"`
}

// NewGooglePlayValidator generates a validator.
func NewGooglePlayValidator(key string) *GooglePlayValidator {
    return &GooglePlayValidator{
        Key: key,
        VerifySignature: func(key string, purchaseData []byte, signature string) (bool, error) {
            return playstore.VerifySignature(key, purchaseData, signature)
        },
    }
}

// Verify verifies in app billing signature and gets order lines.
func (v *GooglePlayValidator) Verify(ctx context.Context, payload string) ([]*model.OrderLine, error) {
    var iabPayload IABPayload
    if err := json.Unmarshal([]byte(payload), &iabPayload); err != nil {
        return nil, domain.ErrInvalidReceipt
    }

    purchaseData := []byte(iabPayload.Json)
    signature := iabPayload.Signature
    isValid, err := v.VerifySignature(v.Key, purchaseData, signature)
    if err != nil {
        return nil, err
    }
    if isValid == false {
        return nil, domain.ErrInvalidReceipt
    }

    var receipt IABReceipt
    if err = json.Unmarshal(purchaseData, &receipt); err != nil {
        return nil, domain.ErrInvalidReceipt
    }

    // もし purchaseState が 0 以外(0: purchased, 1: canceled, 2: refunded) の場合はクライアント起因のエラーとする
    if receipt.PurchaseState != 0 {
        return nil, domain.ErrInvalidReceipt
    }

    // クライアント側で DeveloperPayload に ユーザID を入れてもらう
    if receipt.DeveloperPayload == userId {
        return nil, domain.ErrInvalidReceipt
    }

    var orderLines []*model.OrderLine
    // 略
    orderLines = append(orderLines, order)

    return orderLines, nil
}

GolangでGooglePlayの課金レシートの署名検証を参考。

レシート検証〜保存

クライアントから store(AppStore / GooglePlay)と payload(購入レシート)を送ってもらう。

こんな感じに。

 // レシートの検証を行い、検証後のデータを取得
    validator // AppStoreValidator or GooglePlayValidator
    orderLines, err := validator.Verify(ctx, payload)
    if err != nil {
        return err
    }

    // トランザクション

    // ユーザ毎に排他処理を行う
    // select users for update

    // クライアントから与えられたレシートを保存
    // create receipts

    for _, orderLine := range orderLines {
        // すでに purchases テーブルに TransactionID or OriginalTransactionID が存在していないかチェック
        // isExist

        // purchase 保存
        // create purchases

        // 有償ゴールド保存
        // create paid_golds

        // 履歴保存
        // create gold_histories
    }