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 }