282 lines
8.9 KiB
Go
282 lines
8.9 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
|
|
}
|
|
|
|
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"`
|
|
}
|