2026-07-03 17:35:47 +08:00

259 lines
7.9 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 googleorders 提供 Play Developer API Orders 资源的最小只读客户端。
// hyapp 自有 AppLalu 等)的实付同步走 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
}