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

This commit is contained in:
shanshanzhong 2026-03-10 20:47:24 -07:00
parent 26f6400e74
commit 7c2eddf9c3
5 changed files with 537 additions and 32 deletions

View 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`;

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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,44 +53,112 @@ 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)
// 用 originalTransactionId即 trade_no向 Apple Server API 查询交易详情 } else if ord.AppAccountToken != "" {
jws, e := iapapple.GetTransactionInfo(*apiCfg, ord.TradeNo) // trade_no 为空但有 app_account_token兜底查询
if e != nil { l.reconcileByAppAccountToken(ctx, apiCfg, ord)
logger.Errorf("[IAPReconcile] GetTransactionInfo error: orderNo=%s tradeNo=%s err=%v",
ord.OrderNo, ord.TradeNo, e)
time.Sleep(100 * time.Millisecond)
continue
}
// 解析 JWS不校验证书链只读取 payload
txPayload, e := iapapple.ParseTransactionJWS(jws)
if e != nil {
logger.Errorf("[IAPReconcile] ParseTransactionJWS error: orderNo=%s err=%v", ord.OrderNo, e)
continue
}
if txPayload.RevocationDate != nil {
// 苹果已撤销交易 → 关闭订单
logger.Infof("[IAPReconcile] transaction revoked, closing order: %s", ord.OrderNo)
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)
}
continue
}
// 正常已付款交易 → enqueue 激活activateOrderLogic 内部有幂等保护)
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] enqueue activate error: orderNo=%s err=%v", ord.OrderNo, e)
} else {
logger.Infof("[IAPReconcile] enqueued activate: %s", ord.OrderNo)
} }
time.Sleep(100 * time.Millisecond) // 避免 Apple API 限频 time.Sleep(100 * time.Millisecond) // 避免 Apple API 限频
} }
return nil 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)
if e != nil {
logger.Errorf("[IAPReconcile] GetTransactionInfo error: orderNo=%s tradeNo=%s err=%v",
ord.OrderNo, ord.TradeNo, e)
return
}
txPayload, e := iapapple.ParseTransactionJWS(jws)
if e != nil {
logger.Errorf("[IAPReconcile] ParseTransactionJWS error: orderNo=%s err=%v", ord.OrderNo, e)
return
}
if txPayload.RevocationDate != nil {
logger.Infof("[IAPReconcile] transaction revoked, closing order: %s", ord.OrderNo)
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)
}
return
}
// 正常已付款交易 → 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] enqueue activate error: orderNo=%s err=%v", ord.OrderNo, e)
} else {
logger.Infof("[IAPReconcile] enqueued activate: %s", ord.OrderNo)
}
}
// 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 支付方式配置
func (l *ReconcileLogic) loadAppleAPIConfig(ctx context.Context) (*iapapple.ServerAPIConfig, error) { func (l *ReconcileLogic) loadAppleAPIConfig(ctx context.Context) (*iapapple.ServerAPIConfig, error) {
pays, err := l.svc.PaymentModel.FindListByPlatform(ctx, pkgpayment.AppleIAP.String()) pays, err := l.svc.PaymentModel.FindListByPlatform(ctx, pkgpayment.AppleIAP.String())

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