Golang で iOS/Android アプリ内課金の実装
概要
アプリ内課金の実装に関わったので、メモ。
※アプリ内課金はプラットフォーム側の影響を受ける。記載内容が古くなっている可能性があるので注意。
テーブル設計
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 テーブル
- 有償ゴールドの購入、使用の履歴
レシートの検証
こちらを使わせていただいた。
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 }