2026-07-03 13:09:01 +08:00

347 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}