347 lines
11 KiB
Go
347 lines
11 KiB
Go
package googleplay
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/rsa"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/golang-jwt/jwt/v5"
|
||
"hyapp/pkg/xerr"
|
||
"hyapp/services/wallet-service/internal/config"
|
||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||
)
|
||
|
||
const androidPublisherScope = "https://www.googleapis.com/auth/androidpublisher"
|
||
|
||
// Client implements the minimal Google Play Developer API calls needed by wallet confirm.
|
||
type Client struct {
|
||
httpClient *http.Client
|
||
apiBaseURL string
|
||
tokenURL string
|
||
packageName string
|
||
consumeEnabled bool
|
||
email string
|
||
privateKey *rsa.PrivateKey
|
||
|
||
mu sync.Mutex
|
||
accessToken string
|
||
expiresAt time.Time
|
||
}
|
||
|
||
type serviceAccount struct {
|
||
ClientEmail string `json:"client_email"`
|
||
PrivateKey string `json:"private_key"`
|
||
TokenURI string `json:"token_uri"`
|
||
}
|
||
|
||
// New creates a Google Play Developer API client from wallet-service config.
|
||
func New(cfg config.GooglePlayConfig) (*Client, error) {
|
||
body := strings.TrimSpace(cfg.ServiceAccountJSON)
|
||
if body == "" && strings.TrimSpace(cfg.ServiceAccountFile) != "" {
|
||
fileBody, err := os.ReadFile(strings.TrimSpace(cfg.ServiceAccountFile))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
body = string(fileBody)
|
||
}
|
||
var account serviceAccount
|
||
if err := json.Unmarshal([]byte(body), &account); err != nil {
|
||
return nil, fmt.Errorf("parse google play service account: %w", err)
|
||
}
|
||
account.ClientEmail = strings.TrimSpace(account.ClientEmail)
|
||
account.PrivateKey = strings.TrimSpace(account.PrivateKey)
|
||
account.TokenURI = strings.TrimSpace(account.TokenURI)
|
||
if account.ClientEmail == "" || account.PrivateKey == "" {
|
||
return nil, errors.New("google play service account is incomplete")
|
||
}
|
||
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(account.PrivateKey))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse google play private key: %w", err)
|
||
}
|
||
tokenURL := strings.TrimSpace(cfg.TokenURL)
|
||
if tokenURL == "" {
|
||
tokenURL = account.TokenURI
|
||
}
|
||
if tokenURL == "" {
|
||
tokenURL = "https://oauth2.googleapis.com/token"
|
||
}
|
||
apiBaseURL := strings.TrimRight(strings.TrimSpace(cfg.APIBaseURL), "/")
|
||
if apiBaseURL == "" {
|
||
apiBaseURL = "https://androidpublisher.googleapis.com"
|
||
}
|
||
timeout := cfg.HTTPTimeout
|
||
if timeout <= 0 {
|
||
timeout = 10 * time.Second
|
||
}
|
||
return &Client{
|
||
httpClient: &http.Client{Timeout: timeout},
|
||
apiBaseURL: apiBaseURL,
|
||
tokenURL: tokenURL,
|
||
packageName: strings.TrimSpace(cfg.PackageName),
|
||
consumeEnabled: cfg.ConsumeEnabled,
|
||
email: account.ClientEmail,
|
||
privateKey: privateKey,
|
||
}, nil
|
||
}
|
||
|
||
func (c *Client) GetProductPurchase(ctx context.Context, packageName string, purchaseToken string) (ledger.GooglePlayPurchase, error) {
|
||
if c == nil {
|
||
return ledger.GooglePlayPurchase{}, xerr.New(xerr.Unavailable, "google play client is not configured")
|
||
}
|
||
packageName = c.normalizePackageName(packageName)
|
||
purchaseToken = strings.TrimSpace(purchaseToken)
|
||
if packageName == "" || purchaseToken == "" {
|
||
return ledger.GooglePlayPurchase{}, xerr.New(xerr.InvalidArgument, "google play purchase request is incomplete")
|
||
}
|
||
endpoint := fmt.Sprintf("%s/androidpublisher/v3/applications/%s/purchases/productsv2/tokens/%s",
|
||
c.apiBaseURL,
|
||
url.PathEscape(packageName),
|
||
url.PathEscape(purchaseToken),
|
||
)
|
||
body, err := c.doJSON(ctx, http.MethodGet, endpoint, nil)
|
||
if err != nil {
|
||
return ledger.GooglePlayPurchase{}, err
|
||
}
|
||
var parsed productPurchaseV2
|
||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||
return ledger.GooglePlayPurchase{}, err
|
||
}
|
||
purchase := ledger.GooglePlayPurchase{
|
||
PackageName: packageName,
|
||
OrderID: parsed.OrderID,
|
||
PurchaseState: parsed.PurchaseStateContext.PurchaseState,
|
||
AcknowledgementState: parsed.AcknowledgementState,
|
||
PurchaseCompletionTime: parsed.PurchaseCompletionTime,
|
||
RawJSON: string(body),
|
||
}
|
||
if len(parsed.ProductLineItem) > 0 {
|
||
purchase.ProductID = parsed.ProductLineItem[0].ProductID
|
||
purchase.ConsumptionState = parsed.ProductLineItem[0].ProductOfferDetails.ConsumptionState
|
||
}
|
||
return purchase, nil
|
||
}
|
||
|
||
// GetOrder 拉取 Play Developer API Orders 资源,取回用户实付币种、实付总额、税额和开发者净收入。
|
||
// 需要服务账号在 Play Console 拥有“查看财务数据”权限,否则 Google 返回 403。
|
||
func (c *Client) GetOrder(ctx context.Context, packageName string, orderID string) (ledger.GooglePlayOrder, error) {
|
||
if c == nil {
|
||
return ledger.GooglePlayOrder{}, xerr.New(xerr.Unavailable, "google play client is not configured")
|
||
}
|
||
packageName = c.normalizePackageName(packageName)
|
||
orderID = strings.TrimSpace(orderID)
|
||
if packageName == "" || orderID == "" {
|
||
return ledger.GooglePlayOrder{}, xerr.New(xerr.InvalidArgument, "google play order request is incomplete")
|
||
}
|
||
endpoint := fmt.Sprintf("%s/androidpublisher/v3/applications/%s/orders/%s",
|
||
c.apiBaseURL,
|
||
url.PathEscape(packageName),
|
||
url.PathEscape(orderID),
|
||
)
|
||
body, err := c.doJSON(ctx, http.MethodGet, endpoint, nil)
|
||
if err != nil {
|
||
return ledger.GooglePlayOrder{}, err
|
||
}
|
||
var parsed orderResource
|
||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||
return ledger.GooglePlayOrder{}, err
|
||
}
|
||
order := ledger.GooglePlayOrder{
|
||
OrderID: parsed.OrderID,
|
||
State: parsed.State,
|
||
BuyerCountry: parsed.BuyerAddress.BuyerCountry,
|
||
CurrencyCode: parsed.Total.CurrencyCode,
|
||
TotalAmountMicro: parsed.Total.micro(),
|
||
TaxAmountMicro: parsed.Tax.micro(),
|
||
NetAmountMicro: parsed.DeveloperRevenueInBuyerCurrency.micro(),
|
||
RawJSON: string(body),
|
||
}
|
||
if order.CurrencyCode == "" {
|
||
order.CurrencyCode = parsed.Tax.CurrencyCode
|
||
}
|
||
return order, nil
|
||
}
|
||
|
||
func (c *Client) ConsumeProduct(ctx context.Context, packageName string, productID string, purchaseToken string) error {
|
||
if c == nil {
|
||
return xerr.New(xerr.Unavailable, "google play client is not configured")
|
||
}
|
||
if !c.consumeEnabled {
|
||
return xerr.New(xerr.Unavailable, "google play consume is disabled")
|
||
}
|
||
packageName = c.normalizePackageName(packageName)
|
||
productID = strings.TrimSpace(productID)
|
||
purchaseToken = strings.TrimSpace(purchaseToken)
|
||
if packageName == "" || productID == "" || purchaseToken == "" {
|
||
return xerr.New(xerr.InvalidArgument, "google play consume request is incomplete")
|
||
}
|
||
endpoint := fmt.Sprintf("%s/androidpublisher/v3/applications/%s/purchases/products/%s/tokens/%s:consume",
|
||
c.apiBaseURL,
|
||
url.PathEscape(packageName),
|
||
url.PathEscape(productID),
|
||
url.PathEscape(purchaseToken),
|
||
)
|
||
_, err := c.doJSON(ctx, http.MethodPost, endpoint, bytes.NewReader(nil))
|
||
return err
|
||
}
|
||
|
||
func (c *Client) normalizePackageName(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value != "" {
|
||
return value
|
||
}
|
||
return c.packageName
|
||
}
|
||
|
||
func (c *Client) doJSON(ctx context.Context, method string, endpoint string, body io.Reader) ([]byte, error) {
|
||
token, err := c.token(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
if method != http.MethodGet {
|
||
req.Header.Set("Content-Type", "application/json")
|
||
}
|
||
resp, err := c.httpClient.Do(req)
|
||
if err != nil {
|
||
return nil, xerr.New(xerr.Unavailable, "google play api request failed")
|
||
}
|
||
defer resp.Body.Close()
|
||
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||
if readErr != nil {
|
||
return nil, readErr
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
|
||
return nil, xerr.New(xerr.InvalidArgument, "google play purchase token is invalid")
|
||
}
|
||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||
return nil, xerr.New(xerr.Unavailable, "google play api is not authorized")
|
||
}
|
||
return nil, xerr.New(xerr.Unavailable, "google play api returned non-success")
|
||
}
|
||
return respBody, nil
|
||
}
|
||
|
||
func (c *Client) token(ctx context.Context) (string, error) {
|
||
c.mu.Lock()
|
||
if c.accessToken != "" && time.Now().Before(c.expiresAt.Add(-60*time.Second)) {
|
||
token := c.accessToken
|
||
c.mu.Unlock()
|
||
return token, nil
|
||
}
|
||
c.mu.Unlock()
|
||
|
||
now := time.Now()
|
||
claims := jwt.MapClaims{
|
||
"iss": c.email,
|
||
"scope": androidPublisherScope,
|
||
"aud": c.tokenURL,
|
||
"iat": now.Unix(),
|
||
"exp": now.Add(time.Hour).Unix(),
|
||
}
|
||
assertion, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(c.privateKey)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
form := url.Values{}
|
||
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||
form.Set("assertion", assertion)
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, strings.NewReader(form.Encode()))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
resp, err := c.httpClient.Do(req)
|
||
if err != nil {
|
||
return "", xerr.New(xerr.Unavailable, "google oauth token request failed")
|
||
}
|
||
defer resp.Body.Close()
|
||
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||
if readErr != nil {
|
||
return "", readErr
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return "", xerr.New(xerr.Unavailable, "google oauth token request failed")
|
||
}
|
||
var tokenResp struct {
|
||
AccessToken string `json:"access_token"`
|
||
ExpiresIn int64 `json:"expires_in"`
|
||
TokenType string `json:"token_type"`
|
||
}
|
||
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
|
||
return "", err
|
||
}
|
||
if tokenResp.AccessToken == "" {
|
||
return "", xerr.New(xerr.Unavailable, "google oauth token response is incomplete")
|
||
}
|
||
expiresIn := time.Duration(tokenResp.ExpiresIn) * time.Second
|
||
if expiresIn <= 0 {
|
||
expiresIn = time.Hour
|
||
}
|
||
c.mu.Lock()
|
||
c.accessToken = tokenResp.AccessToken
|
||
c.expiresAt = now.Add(expiresIn)
|
||
c.mu.Unlock()
|
||
return tokenResp.AccessToken, nil
|
||
}
|
||
|
||
type productPurchaseV2 struct {
|
||
ProductLineItem []productLineItem `json:"productLineItem"`
|
||
OrderID string `json:"orderId"`
|
||
PurchaseStateContext purchaseState `json:"purchaseStateContext"`
|
||
PurchaseCompletionTime string `json:"purchaseCompletionTime"`
|
||
AcknowledgementState string `json:"acknowledgementState"`
|
||
}
|
||
|
||
type purchaseState struct {
|
||
PurchaseState string `json:"purchaseState"`
|
||
}
|
||
|
||
type productLineItem struct {
|
||
ProductID string `json:"productId"`
|
||
ProductOfferDetails productOfferDetails `json:"productOfferDetails"`
|
||
}
|
||
|
||
type productOfferDetails struct {
|
||
ConsumptionState string `json:"consumptionState"`
|
||
}
|
||
|
||
type orderResource struct {
|
||
OrderID string `json:"orderId"`
|
||
State string `json:"state"`
|
||
BuyerAddress buyerAddress `json:"buyerAddress"`
|
||
Total moneyAmount `json:"total"`
|
||
Tax moneyAmount `json:"tax"`
|
||
DeveloperRevenueInBuyerCurrency moneyAmount `json:"developerRevenueInBuyerCurrency"`
|
||
}
|
||
|
||
type buyerAddress struct {
|
||
BuyerCountry string `json:"buyerCountry"`
|
||
}
|
||
|
||
// moneyAmount 是 google.type.Money 的 JSON 形态;units 是字符串形式的整数金额,nanos 是 10^-9 小数部分。
|
||
type moneyAmount struct {
|
||
CurrencyCode string `json:"currencyCode"`
|
||
Units json.Number `json:"units"`
|
||
Nanos int64 `json:"nanos"`
|
||
}
|
||
|
||
func (m moneyAmount) micro() int64 {
|
||
units, _ := m.Units.Int64()
|
||
return units*1_000_000 + m.Nanos/1_000
|
||
}
|