简介
项目中使用的是 Google Play 和 Appstore IAP
Google Play采用了 支付完成后由Google服务器通知游戏服务器 的方式,游戏服务器提供的接口必须为 https,请求类型为 post
Appstore 采用了客户端给游戏服务器收据,之后服务器自己拿着 收据向第三方服务器验证 的方式, (只有订阅商品可以走苹果的通知)
使用 go 库
url: https://github.com/awa/go-iap
title: "GitHub - awa/go-iap: go-iap verifies the purchase receipt via AppStore, GooglePlayStore, AmazonAppStore and Huawei HMS."
description: "go-iap verifies the purchase receipt via AppStore, GooglePlayStore, AmazonAppStore and Huawei HMS. - GitHub - awa/go-iap: go-iap verifies the purchase receipt via AppStore, GooglePlayStore, AmazonA..."
host: github.com
favicon: https://github.githubassets.com/favicons/favicon.svg
image: https://opengraph.githubassets.com/082874903e0ef0aef0e79326afb5af592c8b90cf84aff0bb1dc6a0e2cb379311/awa/go-iap
Google Play
文档
url: https://developer.android.com/google/play/billing/rtdn-reference?hl=zh-cn&authuser=1
title: "实时开发者通知参考指南 | Google Play 结算系统 | Android Developers"
host: developer.android.com
image: https://developer.android.com/static/images/social/android-developers.png?authuser=1&hl=zh-cn
url: https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products/get?hl=zh-cn
title: "Method: purchases.products.get | Google Play Developer API | Google for Developers"
host: developers.google.com
image: https://www.gstatic.com/devrel-devsite/prod/vf713985d8e62ba7506345995097e4b76a060f9dc558a369e9b889efae740fb5f/developers/images/opengraph/google-blue.png
配置流程
- 去 google cloud 的 API 和服务 配置 服务账号,下载 google 认证文件
- 在 Pub/Sub 页面,创建 主题和订阅,订阅里设置传送类型为 推送,同时填写要通知的服务器的 URL
- 为主题和订阅添加权限 Pub/Sub Admin,账号为
google-play-developer-notifications@system.gserviceaccount.com
- 去 google play console,用开发者账户 (项目创建者,需支付 25 刀) ,在 API 权限里启用 Google Play Developer API
- 选择程序,在 营利设定 里可以找到 Google Play 付款服务,把之前创建的 主题 填上去,发送测试通知
- 如果成功的话服务器将会收到一条测试消息,格式为 json,其中 data 字段被 base64 加密过,需要解码之后查看内容
相关代码
解析消息
type GooglePlayData struct {
Version string `json:"version"`
PackageName string `json:"packageName"`
EventTimeMillis string `json:"eventTimeMillis"`
OneTimeProductNotification *OneTimeProductNotification `json:"oneTimeProductNotification,omitempty"`
SubscriptionNotification *SubscriptionNotification `json:"subscriptionNotification,omitempty"`
TestNotification *TestNotification `json:"testNotification,omitempty"`
}
type SubscriptionNotification struct {
Version string `json:"version"`
NotificationType int `json:"notificationType"`
PurchaseToken string `json:"purchaseToken"`
SubscriptionId string `json:"subscriptionId"`
}
type OneTimeProductNotification struct {
Version string `json:"version"`
NotificationType int `json:"notificationType"`
PurchaseToken string `json:"purchaseToken"`
Sku string `json:"sku"`
}
type TestNotification struct {
Version string `json:"version"`
}
-------------------------------------------------------------------------------------------------------------------------------------------------
decodeString, err := base64.StdEncoding.DecodeString("data字段")
if err != nil {
return nil, err
}
var gpd GooglePlayData
err = json.Unmarshal(decodeString, &gpd)
if err != nil {
return nil, err
}
认证
GoogleClient, err = playstore.New("key文件")
if err != nil {
log.Fatal(err)
}
// 获取订单信息
GoogleClient.VerifyProduct(context.Background(), PackageName, productId, token)
// 确认订单
GoogleClient.AcknowledgeProduct(ctx, PackageName, ProductId, PurchaseToken, DeveloperPayload)
// 消费订单
GoogleClient.ConsumeProduct(ctx, PackageName, ProductId, PurchaseToken)
踩坑
- 不是所有订单都会触发通知,只有当购买交易从 PENDING 过渡到 PURCHASED 时,应用才会收到
ONE_TIME_PRODUCT_PURCHASED
通知.如果购买交易被取消,应用会收到ONE_TIME_PRODUCT_CANCELED
通知.也就是说,在用测试卡进行支付测试时,如果选择 " 一律批准 " 的交易方式,就不会触发通知,因为没有触发 pending 的过渡. - 使用测试订单进行支付流程的验证时,使用 VerifyProduct 获取到订单信息,里边的 purchaseToken 是空的,进而导致请求时报错,所以要 使用通知消息内带来的 token.
Appstore IAP
文档
适用于苹果订阅
url: https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv2decodedpayload
title: "responseBodyV2DecodedPayload | Apple Developer Documentation"
description: "A decoded payload containing the version 2 notification data."
host: developer.apple.com
favicon: /favicon.ico
image: https://docs.developer.apple.com/tutorials/developer-og.jpg
配置流程
- 去 app store connection 创建 App 内购买项目 的密钥,下载 下来保存,名称为
SubscriptionKey_*.p8
以下流程为订阅类型商品适用:
- 用管理账户在 APP 内的 App 信息 页面,在 App Store 服务器通知 填写生产和沙盒服务器的 URL
- 发送一个测试消息,需要设置 JWT 请求头,用的是第 1 步设置的密钥,详情点 这里
- 如果成功的话,服务器将会收到收到一条 json 格式的
testNotificationToken
,内容为 JWT 加密后的一串数据signedPayload
,解码过后可以看到一些 数据
以下流程为消耗型商品适用:
- 客户端收到苹果的支付成功的回调后,将
TransactionId
传给服务端 - 服务端拿着
TransactionId
去 App store 服务器获取订单信息,验证transactionId
是否一致等操作判断订单合法性
相关代码
发起请求
url: https://developer.apple.com/documentation/appstoreserverapi/generating_tokens_for_api_requests
title: "Generating tokens for API requests | Apple Developer Documentation"
description: "Create JSON Web Tokens signed with your private key to authorize App Store Server API requests."
host: developer.apple.com
favicon: /favicon.ico
image: https://docs.developer.apple.com/tutorials/developer-og.jpg
所有请求都需要一个 Bearer Token, 具体的加密方式为
- 定义
JWT Header
和JWT Payload
- 使用密钥对
JWT对象
进行加密,方式为ES256
,得到加密字符串
- 之后在请求头中加入
Authorization
,值为Bearer 加密字符串
demo:
package main
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"sync"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid")
func main() {
key, err := os.ReadFile("./SubscriptionKey_WRY3WAF666.p8")
if err != nil {
panic(err)
}
c := &StoreConfig{
KeyContent: key, // Loads a .p8 certificate
KeyID: "2X9R4HXF34", // Your private key ID from App Store Connect (Ex: 2X9R4HXF34)
BundleID: "com.xxx.xxx", // Your app’s bundle ID
Issuer: "93fe111e-7a12-426b-bb79-53ce456c183e", // Your issuer ID from the Keys page in App Store Connect (Ex: "57246542-96fe-1a63-e053-0824d011072a")
Sandbox: true, // default is Production
}
t := Token{}
t.WithConfig(c)
err = t.Generate()
if err != nil {
panic(err)
}
fmt.Println(t.Bearer) // 生成的TOKEN
}
type Token struct {
sync.Mutex
KeyContent []byte // Loads a .p8 certificate
KeyID string // Your private key ID from App Store Connect (Ex: 2X9R4HXF34)
BundleID string // Your app’s bundle ID
Issuer string // Your issuer ID from the Keys page in App Store Connect (Ex: "57246542-96fe-1a63-e053-0824d011072a")
Sandbox bool // default is Production
// internal variables AuthKey *ecdsa.PrivateKey // .p8 private key
ExpiredAt int64 // The token’s expiration time, in UNIX time. Tokens that expire more than 60 minutes after the time in iat are not valid (Ex: 1623086400)
Bearer string // Authorized bearer token
}
type StoreConfig struct {
KeyContent []byte // Loads a .p8 certificate
KeyID string // Your private key ID from App Store Connect (Ex: 2X9R4HXF34)
BundleID string // Your app’s bundle ID
Issuer string // Your issuer ID from the Keys page in App Store Connect (Ex: "57246542-96fe-1a63-e053-0824d011072a")
Sandbox bool // default is Production
}
func (t *Token) WithConfig(c *StoreConfig) {
t.KeyContent = append(t.KeyContent[:0:0], c.KeyContent…)
t.KeyID = c.KeyID
t.BundleID = c.BundleID
t.Issuer = c.Issuer
t.Sandbox = c.Sandbox
}
func (t *Token) Generate() error {
key, err := t.passKeyFromByte(t.KeyContent)
if err != nil {
return err
}
t.AuthKey = key
issuedAt := time.Now().Unix()
expiredAt := time.Now().Add(time.Duration(1) * time.Hour).Unix()
jwtToken := &jwt.Token{
Header: map[string]interface{}{
"alg": "ES256",
"kid": t.KeyID,
"typ": "JWT",
},
Claims: jwt.MapClaims{
"iss": t.Issuer,
"iat": issuedAt,
"exp": expiredAt,
"aud": "appstoreconnect-v1",
"nonce": uuid.New(),
"bid": t.BundleID,
},
Method: jwt.SigningMethodES256,
}
bearer, err := jwtToken.SignedString(t.AuthKey)
if err != nil {
return err
}
t.ExpiredAt = expiredAt
t.Bearer = bearer
return nil
}
// passKeyFromByte loads a .p8 certificate from an in memory byte array and returns an *ecdsa.PrivateKey.
func (t *Token) passKeyFromByte(bytes []byte) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode(bytes)
if block == nil {
return nil, ErrAuthKeyInvalidPem
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
switch pk := key.(type) {
case *ecdsa.PrivateKey:
return pk, nil
default:
return nil, ErrAuthKeyInvalidType
}
}
var (
ErrAuthKeyInvalidPem = errors.New("token: AuthKey must be a valid .p8 PEM file")
ErrAuthKeyInvalidType = errors.New("token: AuthKey must be of type ecdsa.PrivateKey")
)
解析 signedTransactionInfo
- 分成三部分, 以
.
分开, 分别是header
,payload
,signature
- 如果只想查看数据, 只需要用
无填充BASE64
解码payload
即可,详情 header
用无填充BASE64
解码后, 里边有alg
和x5c
,用来验证签名,详情- 证书地址
https://www.apple.com/certificateauthority/
,一般是用 Apple Root CA - G3 Root
解析 signedPayload
适用于订阅商品
type PlatformAppleSubscribeIAP struct {
SignedPayload string `json:"signedPayload"`
PayloadData PayloadData `json:"payloadData"`
}
type PayloadData struct {
NotificationType string `json:"notificationType"` //App Store 发送此版本 2 通知的应用内购买事件。
NotificationUUID string `json:"notificationUUID"` //通知的唯一标识符。使用此值来识别重复的通知。
Version string `json:"version"` //App Store 服务器通知版本号,."2.0"
SignedDate float64 `json:"signedDate"` //App Store 签署 JSON Web 签名数据的 UNIX 时间(以毫秒为单位)。
Subtype string `json:"subtype"` //标识通知事件的附加信息。该subtype字段仅针对特定版本 2 通知出现。
Summary *Summary `json:"summary,omitempty"` //当 App Store 服务器完成您为符合条件的订阅者延长订阅续订日期的请求时显示的摘要数据.与data互斥
Data *Data `json:"data,omitempty"` //包含应用程序元数据以及签名的续订和交易信息的对象。与summary互斥
}
type Summary struct {
RequestIdentifier string `json:"requestIdentifier,omitempty"`
Environment string `json:"environment,omitempty"`
AppAppleId int64 `json:"appAppleId,omitempty"`
BundleId string `json:"bundleId,omitempty"`
ProductId string `json:"productId,omitempty"`
StorefrontCountryCodes []string `json:"storefrontCountryCodes,omitempty"`
FailedCount int64 `json:"failedCount,omitempty"`
SucceededCount int64 `json:"succeededCount,omitempty"`
}
type Data struct {
AppAppleId int64 `json:"appAppleId,omitempty"`
BundleId string `json:"bundleId,omitempty"`
BundleVersion string `json:"bundleVersion,omitempty"`
Environment string `json:"environment,omitempty"`
SignedRenewalInfo string `json:"signedRenewalInfo,omitempty"`
SignedTransactionInfo string `json:"signedTransactionInfo,omitempty"`
Status int32 `json:"status,omitempty"`
}
AppleParseClient = appstore.New()
token := jwt.Token{}
err := AppleParseClient.ParseNotificationV2(p.SignedPayload, &token)
if err != nil {
return nil, err
}
bytes, err := json.Marshal(token.Claims.(jwt.MapClaims))
if err != nil {
return nil, err
}
err = json.Unmarshal(bytes, &p.PayloadData)
if err != nil {
return nil, err
}
验证 transaction
消耗型商品和订阅商品通用
key, err := os.ReadFile(fmt.Sprintf("configs/SubscriptionKey_%s.p8", configs.GetCfg().Appleiap.Kid))
if err != nil {
panic(err)
}
c := &api.StoreConfig{
KeyContent: key, // Loads a .p8 certificate
KeyID: configs.GetCfg().Appleiap.Kid, // Your private key ID from App Store Connect (Ex: 2X9R4HXF34)
BundleID: configs.GetCfg().Appleiap.Bid, // Your app’s bundle ID
Issuer: configs.GetCfg().Appleiap.Iss, // Your issuer ID from the Keys page in App Store Connect (Ex: "57246542-96fe-1a63-e053-0824d011072a")
Sandbox: configs.GetCfg().Appleiap.IsSandbox, // default is Production
}
AppleClient := api.NewStoreClient(c)
// 消耗型商品
res, _ := AppleClient.GetTransactionInfo(ctx, p.TransactionId) // 需要先去获取transaction信息
// 通用
// 订阅型商品:苹果通知会直接把SignedTransactionInfo信息带过来,可以直接解析
// 消耗型商品:拿到上一步的transaction信息中的SignedTransactionInfo解析
transaction, err := AppleClient.ParseSignedTransaction(SignedTransactionInfo)
if transaction.TransactionID == p.TransactionId {
// valid
}