feat: 为订单表添加 app_account_token 字段并增强 Apple IAP 对账逻辑,支持通过交易历史记录查找。
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m25s
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m25s
This commit is contained in:
parent
26f6400e74
commit
7c2eddf9c3
5
initialize/migrate/database/02141_order_iap_token.up.sql
Normal file
5
initialize/migrate/database/02141_order_iap_token.up.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- Add subscription_user_id column to order table
|
||||||
|
ALTER TABLE `order` ADD COLUMN `subscription_user_id` bigint NOT NULL DEFAULT 0 COMMENT 'Target user ID for subscription (0=same as UserId)' AFTER `user_id`;
|
||||||
|
|
||||||
|
-- Add app_account_token column to order table for Apple IAP
|
||||||
|
ALTER TABLE `order` ADD COLUMN `app_account_token` varchar(36) DEFAULT NULL COMMENT 'Apple IAP App Account Token (UUID)' AFTER `subscribe_token`;
|
||||||
@ -15,6 +15,7 @@ type Model interface {
|
|||||||
Insert(ctx context.Context, data *Transaction, tx ...*gorm.DB) error
|
Insert(ctx context.Context, data *Transaction, tx ...*gorm.DB) error
|
||||||
FindByOriginalId(ctx context.Context, originalId string) (*Transaction, error)
|
FindByOriginalId(ctx context.Context, originalId string) (*Transaction, error)
|
||||||
FindByUserAndProduct(ctx context.Context, userId int64, productId string) (*Transaction, error)
|
FindByUserAndProduct(ctx context.Context, userId int64, productId string) (*Transaction, error)
|
||||||
|
FindLatestByUserId(ctx context.Context, userId int64) (*Transaction, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type defaultModel struct {
|
type defaultModel struct {
|
||||||
@ -66,3 +67,13 @@ func (m *customModel) FindByUserAndProduct(ctx context.Context, userId int64, pr
|
|||||||
return &data, err
|
return &data, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindLatestByUserId 查找指定用户最近的一条交易记录
|
||||||
|
// 用于获取已知的 originalTransactionId,以便查询 Apple 交易历史
|
||||||
|
func (m *customModel) FindLatestByUserId(ctx context.Context, userId int64) (*Transaction, error) {
|
||||||
|
var data Transaction
|
||||||
|
err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, v interface{}) error {
|
||||||
|
return conn.Model(&Transaction{}).Where("user_id = ?", userId).Order("purchase_at DESC").First(&data).Error
|
||||||
|
})
|
||||||
|
return &data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -129,3 +129,78 @@ func GetTransactionInfo(cfg ServerAPIConfig, transactionId string) (string, erro
|
|||||||
}
|
}
|
||||||
return "", fmt.Errorf("apple api error, primary[%d:%s], secondary[%d:%s]", code, body, code2, body2)
|
return "", fmt.Errorf("apple api error, primary[%d:%s], secondary[%d:%s]", code, body, code2, body2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTransactionHistory 获取指定交易 ID 的完整交易历史(该用户的所有交易)
|
||||||
|
// Apple API: GET /inApps/v2/history/{transactionId}
|
||||||
|
// 返回所有交易的 JWS 列表
|
||||||
|
func GetTransactionHistory(cfg ServerAPIConfig, transactionId string) ([]string, error) {
|
||||||
|
token, err := buildAPIToken(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPage := func(host, revision string) ([]string, string, bool, int, error) {
|
||||||
|
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 "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, _ = buf.ReadFrom(resp.Body)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, "", false, resp.StatusCode, fmt.Errorf("apple api error: %d %s", resp.StatusCode, buf.String())
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
SignedTransactions []string `json:"signedTransactions"`
|
||||||
|
Revision string `json:"revision"`
|
||||||
|
HasMore bool `json:"hasMore"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
|
||||||
|
return nil, "", false, resp.StatusCode, err
|
||||||
|
}
|
||||||
|
return result.SignedTransactions, result.Revision, result.HasMore, resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
primary := "https://api.storekit.itunes.apple.com"
|
||||||
|
secondary := "https://api.storekit-sandbox.itunes.apple.com"
|
||||||
|
if cfg.Sandbox {
|
||||||
|
primary, secondary = secondary, primary
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试 primary 环境
|
||||||
|
var allTx []string
|
||||||
|
revision := ""
|
||||||
|
for {
|
||||||
|
txs, rev, hasMore, _, err := fetchPage(primary, revision)
|
||||||
|
if err != nil {
|
||||||
|
break // primary 失败,尝试 secondary
|
||||||
|
}
|
||||||
|
allTx = append(allTx, txs...)
|
||||||
|
if !hasMore || rev == "" {
|
||||||
|
return allTx, nil
|
||||||
|
}
|
||||||
|
revision = rev
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: secondary 环境
|
||||||
|
allTx = nil
|
||||||
|
revision = ""
|
||||||
|
for {
|
||||||
|
txs, rev, hasMore, code, err := fetchPage(secondary, revision)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("both environments failed, secondary[%d]: %v", code, err)
|
||||||
|
}
|
||||||
|
allTx = append(allTx, txs...)
|
||||||
|
if !hasMore || rev == "" {
|
||||||
|
return allTx, nil
|
||||||
|
}
|
||||||
|
revision = rev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
|
"github.com/perfect-panel/server/internal/model/order"
|
||||||
"github.com/perfect-panel/server/internal/model/payment"
|
"github.com/perfect-panel/server/internal/model/payment"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
|
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
|
||||||
@ -52,32 +53,39 @@ func (l *ReconcileLogic) reconcile(ctx context.Context, minAge, maxAge time.Dura
|
|||||||
logger.Infof("[IAPReconcile] found %d pending IAP orders (requireTradeNo=%v)", len(orders), requireTradeNo)
|
logger.Infof("[IAPReconcile] found %d pending IAP orders (requireTradeNo=%v)", len(orders), requireTradeNo)
|
||||||
|
|
||||||
for _, ord := range orders {
|
for _, ord := range orders {
|
||||||
if ord.TradeNo == "" {
|
if ord.TradeNo != "" {
|
||||||
continue
|
// 有 trade_no:直接用 transactionId 查询 Apple Server API
|
||||||
|
l.reconcileByTradeNo(ctx, apiCfg, ord)
|
||||||
|
} else if ord.AppAccountToken != "" {
|
||||||
|
// trade_no 为空但有 app_account_token:兜底查询
|
||||||
|
l.reconcileByAppAccountToken(ctx, apiCfg, ord)
|
||||||
}
|
}
|
||||||
// 用 originalTransactionId(即 trade_no)向 Apple Server API 查询交易详情
|
time.Sleep(100 * time.Millisecond) // 避免 Apple API 限频
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileByTradeNo 通过 trade_no (originalTransactionId) 直接查询 Apple 交易状态
|
||||||
|
func (l *ReconcileLogic) reconcileByTradeNo(ctx context.Context, apiCfg *iapapple.ServerAPIConfig, ord *order.Order) {
|
||||||
jws, e := iapapple.GetTransactionInfo(*apiCfg, ord.TradeNo)
|
jws, e := iapapple.GetTransactionInfo(*apiCfg, ord.TradeNo)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
logger.Errorf("[IAPReconcile] GetTransactionInfo error: orderNo=%s tradeNo=%s err=%v",
|
logger.Errorf("[IAPReconcile] GetTransactionInfo error: orderNo=%s tradeNo=%s err=%v",
|
||||||
ord.OrderNo, ord.TradeNo, e)
|
ord.OrderNo, ord.TradeNo, e)
|
||||||
time.Sleep(100 * time.Millisecond)
|
return
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
// 解析 JWS(不校验证书链,只读取 payload)
|
|
||||||
txPayload, e := iapapple.ParseTransactionJWS(jws)
|
txPayload, e := iapapple.ParseTransactionJWS(jws)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
logger.Errorf("[IAPReconcile] ParseTransactionJWS error: orderNo=%s err=%v", ord.OrderNo, e)
|
logger.Errorf("[IAPReconcile] ParseTransactionJWS error: orderNo=%s err=%v", ord.OrderNo, e)
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
if txPayload.RevocationDate != nil {
|
if txPayload.RevocationDate != nil {
|
||||||
// 苹果已撤销交易 → 关闭订单
|
|
||||||
logger.Infof("[IAPReconcile] transaction revoked, closing order: %s", ord.OrderNo)
|
logger.Infof("[IAPReconcile] transaction revoked, closing order: %s", ord.OrderNo)
|
||||||
if closeErr := l.svc.OrderModel.UpdateOrderStatus(ctx, ord.OrderNo, 3); closeErr != nil {
|
if closeErr := l.svc.OrderModel.UpdateOrderStatus(ctx, ord.OrderNo, 3); closeErr != nil {
|
||||||
logger.Errorf("[IAPReconcile] close revoked order failed: orderNo=%s err=%v", ord.OrderNo, closeErr)
|
logger.Errorf("[IAPReconcile] close revoked order failed: orderNo=%s err=%v", ord.OrderNo, closeErr)
|
||||||
}
|
}
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
// 正常已付款交易 → enqueue 激活(activateOrderLogic 内部有幂等保护)
|
// 正常已付款交易 → enqueue 激活
|
||||||
payload, _ := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: ord.OrderNo})
|
payload, _ := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: ord.OrderNo})
|
||||||
task := asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)
|
task := asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)
|
||||||
if _, e = l.svc.Queue.EnqueueContext(ctx, task); e != nil {
|
if _, e = l.svc.Queue.EnqueueContext(ctx, task); e != nil {
|
||||||
@ -85,9 +93,70 @@ func (l *ReconcileLogic) reconcile(ctx context.Context, minAge, maxAge time.Dura
|
|||||||
} else {
|
} else {
|
||||||
logger.Infof("[IAPReconcile] enqueued activate: %s", ord.OrderNo)
|
logger.Infof("[IAPReconcile] enqueued activate: %s", ord.OrderNo)
|
||||||
}
|
}
|
||||||
time.Sleep(100 * time.Millisecond) // 避免 Apple API 限频
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
// reconcileByAppAccountToken 兜底:trade_no 为空但有 app_account_token
|
||||||
|
// 通过用户历史交易 ID 查询 Apple 交易历史,匹配 appAccountToken 找到对应交易
|
||||||
|
func (l *ReconcileLogic) reconcileByAppAccountToken(ctx context.Context, apiCfg *iapapple.ServerAPIConfig, ord *order.Order) {
|
||||||
|
// 1. 获取该用户最近的已知交易 ID
|
||||||
|
lastTx, err := l.svc.IAPAppleTransactionModel.FindLatestByUserId(ctx, ord.UserId)
|
||||||
|
if err != nil || lastTx == nil || lastTx.OriginalTransactionId == "" {
|
||||||
|
logger.Infof("[IAPReconcile-Fallback] no historical transaction found for user %d, orderNo=%s, skip",
|
||||||
|
ord.UserId, ord.OrderNo)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("[IAPReconcile-Fallback] searching Apple history for orderNo=%s appAccountToken=%s using transactionId=%s",
|
||||||
|
ord.OrderNo, ord.AppAccountToken, lastTx.OriginalTransactionId)
|
||||||
|
|
||||||
|
// 2. 查询 Apple 交易历史
|
||||||
|
jwsList, err := iapapple.GetTransactionHistory(*apiCfg, lastTx.OriginalTransactionId)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("[IAPReconcile-Fallback] GetTransactionHistory error: orderNo=%s err=%v", ord.OrderNo, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 在交易历史中查找匹配 appAccountToken 的交易
|
||||||
|
for _, jws := range jwsList {
|
||||||
|
txPayload, e := iapapple.ParseTransactionJWS(jws)
|
||||||
|
if e != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if txPayload.AppAccountToken != ord.AppAccountToken {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 匹配成功!
|
||||||
|
logger.Infof("[IAPReconcile-Fallback] MATCHED! orderNo=%s appAccountToken=%s transactionId=%s",
|
||||||
|
ord.OrderNo, ord.AppAccountToken, txPayload.TransactionId)
|
||||||
|
|
||||||
|
if txPayload.RevocationDate != nil {
|
||||||
|
logger.Infof("[IAPReconcile-Fallback] transaction revoked, closing order: %s", ord.OrderNo)
|
||||||
|
if closeErr := l.svc.OrderModel.UpdateOrderStatus(ctx, ord.OrderNo, 3); closeErr != nil {
|
||||||
|
logger.Errorf("[IAPReconcile-Fallback] close revoked order failed: orderNo=%s err=%v", ord.OrderNo, closeErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 trade_no 为找到的 originalTransactionId
|
||||||
|
if updateErr := l.svc.DB.Model(&order.Order{}).
|
||||||
|
Where("order_no = ?", ord.OrderNo).
|
||||||
|
Update("trade_no", txPayload.OriginalTransactionId).Error; updateErr != nil {
|
||||||
|
logger.Errorf("[IAPReconcile-Fallback] update trade_no error: orderNo=%s err=%v", ord.OrderNo, updateErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueue 激活
|
||||||
|
payload, _ := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: ord.OrderNo})
|
||||||
|
task := asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)
|
||||||
|
if _, e := l.svc.Queue.EnqueueContext(ctx, task); e != nil {
|
||||||
|
logger.Errorf("[IAPReconcile-Fallback] enqueue activate error: orderNo=%s err=%v", ord.OrderNo, e)
|
||||||
|
} else {
|
||||||
|
logger.Infof("[IAPReconcile-Fallback] enqueued activate: %s", ord.OrderNo)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("[IAPReconcile-Fallback] no matching transaction found for orderNo=%s appAccountToken=%s (checked %d transactions)",
|
||||||
|
ord.OrderNo, ord.AppAccountToken, len(jwsList))
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadAppleAPIConfig 从 payment 表读取第一个启用的 Apple IAP 支付方式配置
|
// loadAppleAPIConfig 从 payment 表读取第一个启用的 Apple IAP 支付方式配置
|
||||||
|
|||||||
345
scripts/test_apple_lookup.go
Normal file
345
scripts/test_apple_lookup.go
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user