// Package googleorders 提供 Play Developer API Orders 资源的最小只读客户端。 // hyapp 自有 App(Lalu 等)的实付同步走 wallet-service;这里只服务 legacy 账单源(Yumi/Aslan), // 它们的支付发生在外部平台,admin 需要用各自 Play Console 的服务账号直接查订单实付。 package googleorders import ( "context" "crypto/rsa" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "strings" "sync" "time" "github.com/golang-jwt/jwt/v5" ) const ( androidPublisherScope = "https://www.googleapis.com/auth/androidpublisher" defaultAPIBaseURL = "https://androidpublisher.googleapis.com" defaultTokenURL = "https://oauth2.googleapis.com/token" ) // Order 是 orders.get 的实付快照;金额统一为实付币种微单位。 type Order struct { OrderID string State string BuyerCountry string CurrencyCode string TotalAmountMicro int64 TaxAmountMicro int64 // NetAmountMicro 是 Google 扣除服务费和代缴税后的开发者净收入;接口未返回时为 0。 NetAmountMicro int64 } // Config 描述一个 Play Console 服务账号;ServiceAccountJSON 优先于 ServiceAccountFile。 type Config struct { PackageName string ServiceAccountJSON string ServiceAccountFile string HTTPTimeout time.Duration } type serviceAccount struct { ClientEmail string `json:"client_email"` PrivateKey string `json:"private_key"` TokenURI string `json:"token_uri"` } // Client 按服务账号缓存 OAuth token;一个 legacy App 一个实例。 type Client struct { httpClient *http.Client packageName string tokenURL string email string privateKey *rsa.PrivateKey mu sync.Mutex accessToken string expiresAt time.Time } // New 创建 legacy Google Orders 客户端;凭证不完整时返回错误,由调用方决定是否降级为“未配置”。 func New(cfg Config) (*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) } if body == "" { return nil, errors.New("google service account is not configured") } var account serviceAccount if err := json.Unmarshal([]byte(body), &account); err != nil { return nil, fmt.Errorf("parse google service account: %w", err) } account.ClientEmail = strings.TrimSpace(account.ClientEmail) account.PrivateKey = strings.TrimSpace(account.PrivateKey) if account.ClientEmail == "" || account.PrivateKey == "" { return nil, errors.New("google service account is incomplete") } privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(account.PrivateKey)) if err != nil { return nil, fmt.Errorf("parse google private key: %w", err) } tokenURL := strings.TrimSpace(account.TokenURI) if tokenURL == "" { tokenURL = defaultTokenURL } timeout := cfg.HTTPTimeout if timeout <= 0 { timeout = 10 * time.Second } packageName := strings.TrimSpace(cfg.PackageName) if packageName == "" { return nil, errors.New("google package name is required") } return &Client{ httpClient: &http.Client{Timeout: timeout}, packageName: packageName, tokenURL: tokenURL, email: account.ClientEmail, privateKey: privateKey, }, nil } // GetOrder 查询单笔订单实付明细;服务账号需要该 Play Console 的“查看财务数据”权限。 func (c *Client) GetOrder(ctx context.Context, orderID string) (Order, error) { if c == nil { return Order{}, errors.New("google orders client is not configured") } orderID = strings.TrimSpace(orderID) if orderID == "" { return Order{}, errors.New("google order id is required") } token, err := c.token(ctx) if err != nil { return Order{}, err } endpoint := fmt.Sprintf("%s/androidpublisher/v3/applications/%s/orders/%s", defaultAPIBaseURL, url.PathEscape(c.packageName), url.PathEscape(orderID)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return Order{}, err } req.Header.Set("Authorization", "Bearer "+token) resp, err := c.httpClient.Do(req) if err != nil { return Order{}, errors.New("google play api request failed") } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return Order{}, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { switch resp.StatusCode { case http.StatusNotFound, http.StatusBadRequest: return Order{}, errors.New("google order is not found") case http.StatusUnauthorized, http.StatusForbidden: return Order{}, errors.New("google play api is not authorized (需要查看财务数据权限)") default: return Order{}, fmt.Errorf("google play api returned %d", resp.StatusCode) } } var parsed orderResource if err := json.Unmarshal(body, &parsed); err != nil { return Order{}, err } order := Order{ 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(), } if order.CurrencyCode == "" { order.CurrencyCode = parsed.Tax.CurrencyCode } return order, 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 "", errors.New("google oauth token request failed") } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", errors.New("google oauth token request failed") } var tokenResp struct { AccessToken string `json:"access_token"` ExpiresIn int64 `json:"expires_in"` } if err := json.Unmarshal(body, &tokenResp); err != nil { return "", err } if tokenResp.AccessToken == "" { return "", errors.New("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 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 }