259 lines
7.9 KiB
Go
259 lines
7.9 KiB
Go
// 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
|
||
}
|