All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m25s
346 lines
10 KiB
Go
346 lines
10 KiB
Go
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
|