2026-05-26 01:02:12 +08:00

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"`
}