feat: 为订单表添加 app_account_token 字段并增强 Apple IAP 对账逻辑,支持通过交易历史记录查找。
Build docker and publish / build (20.15.1) (push) Successful in 7m25s
Build docker and publish / build (20.15.1) (push) Successful in 7m25s
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
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
|
||||
Reference in New Issue
Block a user