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
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
|
|
}
|
|
|