You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

281 lines
8.5 KiB

package appleiap
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
type appleIAP struct {
opt Options
httpc *http.Client
}
func newSys(opt Options) (ISys, error) {
return &appleIAP{
opt: opt,
httpc: &http.Client{Timeout: 8 * time.Second},
}, nil
}
// 生成商户订单号(与其他支付系统一致)
func (this *appleIAP) GenerateOrderNo(prefix string) string {
timeStr := time.Now().Format("20060102150405")
rand.Seed(time.Now().UnixNano())
randNum := rand.Intn(900000) + 100000
return fmt.Sprintf("%s%s%d", prefix, timeStr, randNum)
}
// VerifyReceipt 骨架实现:直接返回 true。
// 可接入 legacy verifyReceipt:
// POST https://buy.itunes.apple.com/verifyReceipt 或 sandbox 验证地址
// 也可迁移到 App Store Server API,推荐优先使用新接口。
func (this *appleIAP) VerifyReceipt(ctx context.Context, receiptData string) (bool, error) {
if this.opt.Debug && this.opt.Log != nil {
this.opt.Log.Infof("[AppleIAP] verify receipt: sandbox=%v data=%s", this.opt.UseSandbox, mask(receiptData))
}
rd := strings.TrimSpace(receiptData)
{
parts := strings.Split(rd, ".")
if len(parts) == 3 {
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err == nil {
var j map[string]any
if json.Unmarshal(payload, &j) == nil {
if txid, ok := j["transactionId"].(string); ok && txid != "" {
return this.VerifyTransaction(ctx, txid)
}
}
}
}
}
if strings.HasPrefix(rd, "{") && strings.HasSuffix(rd, "}") {
var tmp map[string]any
if json.Unmarshal([]byte(rd), &tmp) == nil {
if v, ok := tmp["receipt-data"].(string); ok && strings.TrimSpace(v) != "" {
rd = strings.TrimSpace(v)
} else if v, ok := tmp["latest_receipt"].(string); ok && strings.TrimSpace(v) != "" {
rd = strings.TrimSpace(v)
}
}
}
if _, err := base64.StdEncoding.DecodeString(rd); err != nil {
return false, fmt.Errorf("apple verifyReceipt failed, status=21002: invalid receipt-data base64")
}
receiptData = rd
// Apple legacy verifyReceipt endpoints
const prodURL = "https://buy.itunes.apple.com/verifyReceipt"
const sandboxURL = "https://sandbox.itunes.apple.com/verifyReceipt"
// Build request body
body := map[string]any{
"receipt-data": receiptData,
"exclude-old-transactions": true,
}
if this.opt.SharedSecret != "" {
body["password"] = this.opt.SharedSecret
}
// Helper to POST and parse status
post := func(url string) (int64, map[string]any, error) {
data, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return 0, nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := this.httpc.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return 0, nil, err
}
var out map[string]any
if err = json.Unmarshal(b, &out); err != nil {
return 0, nil, err
}
// status 0 success; 21007 sandbox receipt sent to production
var status int64
if v, ok := out["status"].(float64); ok {
status = int64(v)
}
return status, out, nil
}
// Decide initial endpoint
url := prodURL
if this.opt.UseSandbox {
url = sandboxURL
}
status, _, err := post(url)
if err != nil {
return false, err
}
// Auto fallback on 21007 when hitting production with sandbox receipt
if status == 21007 && url == prodURL {
status, _, err = post(sandboxURL)
if err != nil {
return false, err
}
}
// Success only when status == 0
if status != 0 {
return false, fmt.Errorf("apple verifyReceipt failed, status=%d: %s", status, statusMessage(status))
}
return true, nil
}
// VerifyTransaction 骨架实现:直接返回 true。
// 可接入 App Store Server API:
// GET https://api.appstoreconnect.apple.com/inApps/v1/transactions/{transactionId}
func (this *appleIAP) VerifyTransaction(ctx context.Context, transactionId string) (bool, error) {
if this.opt.Debug && this.opt.Log != nil {
this.opt.Log.Infof("[AppleIAP] verify tx: sandbox=%v txid=%s", this.opt.UseSandbox, mask(transactionId))
}
// Require App Store Server API credentials
if this.opt.AppStoreIssuerID == "" || this.opt.AppStoreKeyID == "" || this.opt.AppStorePrivateKeyPEM == "" {
return false, fmt.Errorf("缺少 App Store Server API 配置: 需 IssuerID, KeyID, PrivateKeyPEM")
}
// StoreKit inApps 必须在 JWT 载荷中携带 bid(bundleId)以通过授权
if this.opt.AppStoreBundleID == "" {
return false, fmt.Errorf("缺少 App Store bundleId: 请在 Options.AppStoreBundleID 设置目标应用的 bundleId")
}
// Build ES256 JWT for App Store Server API
privKey, err := jwt.ParseECPrivateKeyFromPEM([]byte(this.opt.AppStorePrivateKeyPEM))
if err != nil {
return false, fmt.Errorf("解析私钥失败: %w", err)
}
claims := jwt.MapClaims{
"iss": this.opt.AppStoreIssuerID,
"iat": time.Now().Unix(),
// App Store Connect / StoreKit 要求 token 生命周期 ≤ 20 分钟
"exp": time.Now().Add(20 * time.Minute).Unix(),
"aud": "appstoreconnect-v1",
"bid": this.opt.AppStoreBundleID,
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = this.opt.AppStoreKeyID
token.Header["typ"] = "JWT"
signedJWT, err := token.SignedString(privKey)
if err != nil {
return false, fmt.Errorf("签名 JWT 失败: %w", err)
}
base := "https://api.storekit.itunes.apple.com/inApps/v1/transactions/"
if this.opt.UseSandbox {
base = "https://api.storekit-sandbox.itunes.apple.com/inApps/v1/transactions/"
}
url := base + transactionId
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false, err
}
req.Header.Set("Authorization", "Bearer "+signedJWT)
resp, err := this.httpc.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
// 200 认为有效;其余状态视为失败
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return false, fmt.Errorf("Apple transactions 查询失败: %d %s", resp.StatusCode, string(b))
}
// 响应包含 JWS;解析 payload 并进行基本一致性校验
var out struct {
SignedTransactionInfo string `json:"signedTransactionInfo"`
// 可能存在 signedRenewalInfo 等字段,这里无需使用
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return false, err
}
if out.SignedTransactionInfo == "" {
return false, fmt.Errorf("Apple 返回缺少 signedTransactionInfo")
}
parts := strings.Split(out.SignedTransactionInfo, ".")
if len(parts) != 3 {
return false, fmt.Errorf("Apple 返回的 signedTransactionInfo 非法")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return false, fmt.Errorf("解析 Apple signedTransactionInfo 失败: %v", err)
}
var info struct {
BundleID string `json:"bundleId"`
TransactionID string `json:"transactionId"`
Environment string `json:"environment"`
}
if err := json.Unmarshal(payload, &info); err != nil {
return false, fmt.Errorf("解析 Apple 交易载荷失败: %v", err)
}
if info.BundleID == "" || !strings.EqualFold(info.BundleID, this.opt.AppStoreBundleID) {
return false, fmt.Errorf("交易归属的 bundleId 不匹配: %s != %s", info.BundleID, this.opt.AppStoreBundleID)
}
if info.TransactionID == "" || info.TransactionID != transactionId {
return false, fmt.Errorf("返回的 transactionId 不一致: %s != %s", info.TransactionID, transactionId)
}
if this.opt.UseSandbox {
if !strings.EqualFold(info.Environment, "Sandbox") {
return false, fmt.Errorf("交易环境不匹配(期望Sandbox): %s", info.Environment)
}
} else {
if strings.EqualFold(info.Environment, "Sandbox") {
return false, fmt.Errorf("交易环境不匹配(期望Production): %s", info.Environment)
}
}
return true, nil
}
func mask(s string) string {
if len(s) <= 6 {
return "***"
}
return fmt.Sprintf("%s***%s", s[:3], s[len(s)-3:])
}
func statusMessage(code int64) string {
switch code {
case 0:
return "OK"
case 21000:
return "Bad JSON"
case 21001:
return "App Store unavailable"
case 21002:
return "Malformed or missing receipt-data"
case 21003:
return "Receipt cannot be authenticated"
case 21004:
return "Shared secret mismatch"
case 21005:
return "Server unavailable, try again"
case 21006:
return "Subscription expired"
case 21007:
return "Sandbox receipt sent to production"
case 21008:
return "Production receipt sent to sandbox"
default:
return "Unknown error"
}
}