hi-server/scripts/test_apple_lookup.go
2026-03-10 20:47:24 -07:00

346 lines
10 KiB
Go
Raw Permalink 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 main
// Apple App Store Server API 测试脚本
// 用法: go run scripts/test_apple_lookup.go
// 功能: 通过 Apple Server API 获取交易历史,并按 appAccountToken (UUID) 过滤匹配交易
import (
"bytes"
"crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// ==================== 配置区域 ====================
// 请填入你的 Apple App Store Connect API 凭证
const (
keyID = "" // App Store Connect Key ID
issuerID = "" // App Store Connect Issuer ID
bundleID = "com.hifastvpn.vip" // 你的 App Bundle ID
sandbox = true // true=沙盒环境, false=生产环境
)
// 私钥内容 (PEM 格式)
// 从 App Store Connect 下载的 .p8 文件内容
var privateKeyPEM = ``
// ==================== 主逻辑 ====================
func main() {
if len(os.Args) < 2 {
fmt.Println("用法:")
fmt.Println(" go run scripts/test_apple_lookup.go <originalTransactionId> [appAccountToken]")
fmt.Println("")
fmt.Println("参数:")
fmt.Println(" originalTransactionId Apple 交易原始 ID")
fmt.Println(" appAccountToken 可选, 用于过滤的 UUID (服务端下单时生成)")
fmt.Println("")
fmt.Println("示例:")
fmt.Println(" go run scripts/test_apple_lookup.go 2000001132940893")
fmt.Println(" go run scripts/test_apple_lookup.go 2000001132940893 f0eb8c62-4be9-4be7-9266-58d1a0a4e7bf")
os.Exit(1)
}
originalTransactionId := os.Args[1]
filterToken := ""
if len(os.Args) >= 3 {
filterToken = os.Args[2]
}
if keyID == "" || issuerID == "" || privateKeyPEM == "" {
fmt.Println("❌ 请先在脚本中填入 Apple API 凭证 (keyID, issuerID, privateKeyPEM)")
os.Exit(1)
}
fmt.Println("═══════════════════════════════════════════════")
fmt.Println(" Apple App Store Server API - 交易历史查询")
fmt.Println("═══════════════════════════════════════════════")
fmt.Printf(" 环境: %s\n", envName())
fmt.Printf(" TransactionID: %s\n", originalTransactionId)
if filterToken != "" {
fmt.Printf(" 过滤 Token: %s\n", filterToken)
}
fmt.Println("═══════════════════════════════════════════════")
// 1. 生成 JWT
token, err := buildJWT()
if err != nil {
fmt.Printf("❌ 生成 JWT 失败: %v\n", err)
os.Exit(1)
}
fmt.Println("✅ JWT 生成成功")
// 2. 查询交易历史
fmt.Printf("\n📡 正在查询交易历史...\n")
transactions, err := getTransactionHistory(token, originalTransactionId)
if err != nil {
fmt.Printf("❌ 查询失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ 共获取 %d 条交易记录\n\n", len(transactions))
// 3. 解析并展示交易
for i, jws := range transactions {
info, err := parseJWS(jws)
if err != nil {
fmt.Printf(" [%d] ❌ 解析失败: %v\n", i+1, err)
continue
}
txToken := info["appAccountToken"]
matched := ""
if filterToken != "" && fmt.Sprintf("%v", txToken) == filterToken {
matched = " ✅ 匹配!"
}
fmt.Printf(" [%d]%s\n", i+1, matched)
fmt.Printf(" TransactionID: %v\n", info["transactionId"])
fmt.Printf(" OriginalTxID: %v\n", info["originalTransactionId"])
fmt.Printf(" ProductID: %v\n", info["productId"])
fmt.Printf(" AppAccountToken: %v\n", txToken)
fmt.Printf(" PurchaseDate: %v\n", formatTimestamp(info["purchaseDate"]))
fmt.Printf(" Type: %v\n", info["type"])
fmt.Println()
}
// 4. 如果指定了过滤 token显示匹配结果
if filterToken != "" {
found := false
for _, jws := range transactions {
info, err := parseJWS(jws)
if err != nil {
continue
}
if fmt.Sprintf("%v", info["appAccountToken"]) == filterToken {
found = true
fmt.Println("═══════════════════════════════════════════════")
fmt.Printf("🎯 找到匹配的交易AppAccountToken: %s\n", filterToken)
fmt.Printf(" TransactionID: %v\n", info["transactionId"])
fmt.Printf(" ProductID: %v\n", info["productId"])
fmt.Println("═══════════════════════════════════════════════")
break
}
}
if !found {
fmt.Println("═══════════════════════════════════════════════")
fmt.Printf("⚠️ 未找到 AppAccountToken=%s 的交易\n", filterToken)
fmt.Println("═══════════════════════════════════════════════")
}
}
}
// ==================== Apple API 调用 ====================
// getTransactionHistory 获取指定交易 ID 的完整交易历史
// Apple API: GET /inApps/v2/history/{transactionId}
func getTransactionHistory(jwt, transactionId string) ([]string, error) {
var allTransactions []string
revision := ""
for {
host := apiHost()
url := fmt.Sprintf("%s/inApps/v2/history/%s?sort=DESCENDING", host, transactionId)
if revision != "" {
url += "&revision=" + revision
}
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+jwt)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP 请求失败: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
// 尝试另一个环境
host2 := apiHostSecondary()
url2 := fmt.Sprintf("%s/inApps/v2/history/%s?sort=DESCENDING", host2, transactionId)
if revision != "" {
url2 += "&revision=" + revision
}
req2, _ := http.NewRequest("GET", url2, nil)
req2.Header.Set("Authorization", "Bearer "+jwt)
resp2, err2 := http.DefaultClient.Do(req2)
if err2 != nil {
return nil, fmt.Errorf("两个环境都失败: primary[%d:%s]", resp.StatusCode, string(body))
}
defer resp2.Body.Close()
body2, _ := io.ReadAll(resp2.Body)
if resp2.StatusCode != 200 {
return nil, fmt.Errorf("两个环境都失败: primary[%d:%s], secondary[%d:%s]",
resp.StatusCode, string(body), resp2.StatusCode, string(body2))
}
body = body2
}
var result struct {
SignedTransactions []string `json:"signedTransactions"`
Revision string `json:"revision"`
HasMore bool `json:"hasMore"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %v, body: %s", err, string(body))
}
allTransactions = append(allTransactions, result.SignedTransactions...)
if !result.HasMore || result.Revision == "" {
break
}
revision = result.Revision
}
return allTransactions, nil
}
// ==================== JWT 构建 ====================
// buildJWT 构建 Apple Server API 的 ES256 JWT Token
func buildJWT() (string, error) {
header := map[string]interface{}{
"alg": "ES256",
"kid": keyID,
"typ": "JWT",
}
now := time.Now().Unix()
payload := map[string]interface{}{
"iss": issuerID,
"iat": now,
"exp": now + 1800,
"aud": "appstoreconnect-v1",
}
if bundleID != "" {
payload["bid"] = bundleID
}
hb, _ := json.Marshal(header)
pb, _ := json.Marshal(payload)
enc := func(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
unsigned := fmt.Sprintf("%s.%s", enc(hb), enc(pb))
key := fixPEM(privateKeyPEM)
block, _ := pem.Decode([]byte(key))
if block == nil {
return "", fmt.Errorf("invalid private key PEM")
}
keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("parse private key failed: %v", err)
}
priv, ok := keyAny.(*ecdsa.PrivateKey)
if !ok {
return "", fmt.Errorf("private key is not ECDSA")
}
h := sha256.New()
h.Write([]byte(unsigned))
digest := h.Sum(nil)
r, s, err := ecdsa.Sign(rand.Reader, priv, digest)
if err != nil {
return "", err
}
curveBits := priv.Curve.Params().BitSize
keyBytes := curveBits / 8
if curveBits%8 > 0 {
keyBytes++
}
rBytes := r.Bytes()
rPadded := make([]byte, keyBytes)
copy(rPadded[keyBytes-len(rBytes):], rBytes)
sBytes := s.Bytes()
sPadded := make([]byte, keyBytes)
copy(sPadded[keyBytes-len(sBytes):], sBytes)
sig := append(rPadded, sPadded...)
return unsigned + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}
// ==================== 工具函数 ====================
// parseJWS 解析 Apple 返回的 JWS (只取 payload 部分)
func parseJWS(jws string) (map[string]interface{}, error) {
parts := strings.Split(jws, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWS format")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
// 尝试标准 base64
payload, err = base64.RawStdEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("decode payload failed: %v", err)
}
}
var result map[string]interface{}
if err := json.Unmarshal(payload, &result); err != nil {
return nil, fmt.Errorf("unmarshal payload failed: %v", err)
}
return result, nil
}
// formatTimestamp 格式化 Apple 返回的毫秒时间戳
func formatTimestamp(v interface{}) string {
if v == nil {
return "N/A"
}
switch t := v.(type) {
case float64:
ts := time.UnixMilli(int64(t))
return ts.Format("2006-01-02 15:04:05")
default:
return fmt.Sprintf("%v", v)
}
}
func fixPEM(key string) string {
if !strings.Contains(key, "\n") && strings.Contains(key, "BEGIN PRIVATE KEY") {
key = strings.ReplaceAll(key, " ", "\n")
key = strings.ReplaceAll(key, "-----BEGIN\nPRIVATE\nKEY-----", "-----BEGIN PRIVATE KEY-----")
key = strings.ReplaceAll(key, "-----END\nPRIVATE\nKEY-----", "-----END PRIVATE KEY-----")
}
return key
}
func apiHost() string {
if sandbox {
return "https://api.storekit-sandbox.itunes.apple.com"
}
return "https://api.storekit.itunes.apple.com"
}
func apiHostSecondary() string {
if sandbox {
return "https://api.storekit.itunes.apple.com"
}
return "https://api.storekit-sandbox.itunes.apple.com"
}
func envName() string {
if sandbox {
return "🏖️ Sandbox"
}
return "🏭 Production"
}
// 忽略未使用导入
var _ = bytes.NewBuffer