You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

174 lines
6.3 KiB

package googleiap
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"strings"
"time"
"golang.org/x/oauth2/google"
)
type googleIAP struct {
opt Options
httpc *http.Client
}
func newSys(opt Options) (ISys, error) {
return &googleIAP{
opt: opt,
httpc: &http.Client{Timeout: 8 * time.Second},
}, nil
}
// 生成商户订单号(与其他支付系统一致)
func (p *googleIAP) GenerateOrderNo(prefix string) string {
timeStr := time.Now().Format("20060102150405")
rand.Seed(time.Now().UnixNano())
randNum := rand.Intn(900000) + 100000
return fmt.Sprintf("%s%s%d", prefix, timeStr, randNum)
}
// VerifyProductPurchase 使用 Android Publisher API 校验一次性商品的购买
// GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/products/{productId}/tokens/{token}
func (g *googleIAP) VerifyProductPurchase(ctx context.Context, packageName, productId, purchaseToken string) (bool, error) {
if g.opt.Debug && g.opt.Log != nil {
g.opt.Log.Infof("[GoogleIAP] verify product: pkg=%s product=%s token=%s", packageName, productId, maskToken(purchaseToken))
}
token, err := g.getAccessToken(ctx)
if err != nil {
return false, err
}
url := fmt.Sprintf("https://androidpublisher.googleapis.com/androidpublisher/v3/applications/%s/purchases/products/%s/tokens/%s", packageName, productId, purchaseToken)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := g.httpc.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
msg := fmt.Sprintf("Google products 查询失败: %d %s", resp.StatusCode, string(b))
if resp.StatusCode == http.StatusUnauthorized && strings.Contains(string(b), "permissionDenied") {
msg = fmt.Sprintf("Google products 查询失败: 401 permissionDenied: 请在 Google Play Console 的 API 访问为服务账号授予对应用 %s 的权限", packageName)
}
return false, fmt.Errorf("%s", msg)
}
var out struct {
PurchaseState int64 `json:"purchaseState"`
ConsumptionState int64 `json:"consumptionState"`
DeveloperPayload string `json:"developerPayload"`
// acknowledgementState 不总是出现在产品响应中
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return false, err
}
// 规则:purchaseState == 0 表示已购买;其余视为无效
if out.PurchaseState != 0 {
return false, fmt.Errorf("产品未处于购买状态: purchaseState=%d", out.PurchaseState)
}
return true, nil
}
// VerifySubscriptionPurchase 使用 Android Publisher API 校验订阅
// GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptions/{subscriptionId}/tokens/{token}
func (g *googleIAP) VerifySubscriptionPurchase(ctx context.Context, packageName, subscriptionId, purchaseToken string) (bool, error) {
if g.opt.Debug && g.opt.Log != nil {
g.opt.Log.Infof("[GoogleIAP] verify subscription: pkg=%s sub=%s token=%s", packageName, subscriptionId, maskToken(purchaseToken))
}
token, err := g.getAccessToken(ctx)
if err != nil {
return false, err
}
url := fmt.Sprintf("https://androidpublisher.googleapis.com/androidpublisher/v3/applications/%s/purchases/subscriptions/%s/tokens/%s", packageName, subscriptionId, purchaseToken)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := g.httpc.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
msg := fmt.Sprintf("Google subscriptions 查询失败: %d %s", resp.StatusCode, string(b))
if resp.StatusCode == http.StatusUnauthorized && strings.Contains(string(b), "permissionDenied") {
msg = fmt.Sprintf("Google subscriptions 查询失败: 401 permissionDenied: 请在 Google Play Console 的 API 访问为服务账号授予对应用 %s 的权限", packageName)
}
return false, fmt.Errorf("%s", msg)
}
var out struct {
AcknowledgementState int64 `json:"acknowledgementState"`
CancelReason int64 `json:"cancelReason"`
ExpiryTimeMillis string `json:"expiryTimeMillis"`
PaymentState int64 `json:"paymentState"`
// 其他字段:autoRenewing, kind 等
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return false, err
}
// 取消原因非 0 视为已取消
if out.CancelReason != 0 {
return false, fmt.Errorf("订阅已取消: cancelReason=%d", out.CancelReason)
}
// 检查过期时间
if out.ExpiryTimeMillis != "" {
// expiryTimeMillis 是毫秒字符串
var ms int64
_, err := fmt.Sscanf(out.ExpiryTimeMillis, "%d", &ms)
if err == nil {
if ms <= time.Now().UnixMilli() {
return false, fmt.Errorf("订阅已过期: expiry=%s", out.ExpiryTimeMillis)
}
}
}
// 如果提供了 paymentState,则要求为 1(已支付)或 2(免费试用也可视为有效视业务而定)。此处要求至少为 1。
if out.PaymentState != 0 && out.PaymentState < 1 {
return false, fmt.Errorf("订阅支付状态异常: paymentState=%d", out.PaymentState)
}
return true, nil
}
func maskToken(t string) string {
if len(t) <= 6 {
return "***"
}
return fmt.Sprintf("%s***%s", t[:3], t[len(t)-3:])
}
// 获取 Android Publisher 访问令牌
func (g *googleIAP) getAccessToken(ctx context.Context) (string, error) {
var jsonData []byte
if g.opt.ServiceAccountJSON != "" {
jsonData = []byte(g.opt.ServiceAccountJSON)
} else if g.opt.ServiceAccountJSONPath != "" {
b, err := os.ReadFile(g.opt.ServiceAccountJSONPath)
if err != nil {
return "", fmt.Errorf("读取服务账号文件失败: %w", err)
}
jsonData = b
} else {
return "", fmt.Errorf("未配置 ServiceAccountJSON 或 ServiceAccountJSONPath")
}
// 使用服务账号密钥创建 JWT 配置,Android Publisher scope
config, err := google.JWTConfigFromJSON(jsonData, "https://www.googleapis.com/auth/androidpublisher")
if err != nil {
return "", fmt.Errorf("解析服务账号 JSON 失败: %w", err)
}
tok, err := config.TokenSource(ctx).Token()
if err != nil {
return "", fmt.Errorf("获取访问令牌失败: %w", err)
}
return tok.AccessToken, nil
}