feat: 为订单表添加 app_account_token 字段并增强 Apple IAP 对账逻辑,支持通过交易历史记录查找。
Build docker and publish / build (20.15.1) (push) Successful in 7m25s

This commit is contained in:
2026-03-10 20:47:24 -07:00
parent 26f6400e74
commit 7c2eddf9c3
5 changed files with 537 additions and 32 deletions
+345
View File
@@ -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