kra-oa/internal/integration/payment/apple.go

270 lines
8.8 KiB
Go

package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"kra/internal/biz"
gopayApple "github.com/go-pay/gopay/apple"
"github.com/google/uuid"
)
type appleAdapter struct{}
func (a *appleAdapter) Create(_ context.Context, req *biz.PaymentRequest, _ map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("Apple 内购下单参数为空")
}
productID := ""
if req.Extra != nil {
productID, _ = req.Extra["product_id"].(string)
}
if productID == "" {
return nil, errors.New("Apple 内购缺少 extra.product_id")
}
if _, err := uuid.Parse(req.TradeNo); err != nil {
return nil, errors.New("Apple 内购 tradeNo 必须是用于 appAccountToken 的 UUID")
}
payload, _ := json.Marshal(map[string]any{"product_id": productID, "app_account_token": req.TradeNo, "client_only": true, "trade_no": req.TradeNo})
return &biz.PaymentResult{Provider: biz.PaymentApple, Status: "client_pending", TradeNo: req.TradeNo, Payload: payload}, nil
}
func (a *appleAdapter) Query(ctx context.Context, transactionID string, c map[string]any) (*biz.PaymentResult, error) {
transactionID = strings.TrimSpace(transactionID)
if transactionID == "" {
return nil, errors.New("Apple 查询缺少 transactionId")
}
client, err := gopayApple.NewClient(
text(c, "issuer_id"),
text(c, "bundle_id"),
text(c, "key_id"),
text(c, "private_key"),
!strings.EqualFold(text(c, "environment"), "sandbox"),
)
if err != nil {
return nil, err
}
response, err := client.GetTransactionInfo(ctx, transactionID)
if err != nil {
return nil, err
}
if response == nil || response.SignedTransactionInfo == "" {
return nil, errors.New("Apple 查询响应缺少 signedTransactionInfo")
}
if err = validateAppleJWSChain(response.SignedTransactionInfo); err != nil {
return nil, err
}
transaction, err := response.DecodeSignedTransaction()
if err != nil {
return nil, err
}
claims, err := appleClaims(transaction)
if err != nil {
return nil, err
}
if bundleID := text(c, "bundle_id"); bundleID != "" && fmt.Sprint(claims["bundleId"]) != bundleID {
return nil, errors.New("Apple 交易 bundleId 不匹配")
}
if err = validateAppleEnvironment(claims, c); err != nil {
return nil, err
}
payload, _ := json.Marshal(claims)
result, err := applePaymentResult(transactionID, claims, payload, c)
if err != nil {
return nil, err
}
if result.ProviderTradeNo == "" {
return nil, errors.New("Apple 交易缺少 transactionId")
}
return result, nil
}
func (a *appleAdapter) Refund(context.Context, *biz.PaymentRefundRequest, map[string]any) (*biz.PaymentResult, error) {
return nil, errors.New("Apple 内购退款由 App Store 管理,服务端不提供主动退款接口")
}
func (a *appleAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("Apple 回调为空")
}
var body struct {
SignedPayload string `json:"signedPayload"`
}
if err := json.Unmarshal(callback.Body, &body); err != nil {
return nil, err
}
if body.SignedPayload == "" {
return nil, errors.New("Apple 回调缺少 signedPayload")
}
if err := validateAppleJWSChain(body.SignedPayload); err != nil {
return nil, err
}
notification, err := gopayApple.DecodeSignedPayload(body.SignedPayload)
if err != nil {
return nil, err
}
if notificationID := strings.TrimSpace(notification.NotificationUUID); notificationID != "" {
if callback.Query == nil {
callback.Query = map[string]string{}
}
callback.Query["event_id"] = notificationID
}
if notification.Data == nil {
return nil, errors.New("Apple 回调缺少 data")
}
if err = validateAppleJWSChain(notification.Data.SignedTransactionInfo); err != nil {
return nil, err
}
transaction, err := notification.DecodeTransactionInfo()
if err != nil {
return nil, err
}
claims, err := appleClaims(transaction)
if err != nil {
return nil, err
}
if bundleID := text(c, "bundle_id"); bundleID != "" && fmt.Sprint(claims["bundleId"]) != bundleID {
return nil, errors.New("Apple 回调 bundleId 不匹配")
}
if err = validateAppleEnvironment(claims, c); err != nil {
return nil, err
}
payload, _ := json.Marshal(claims)
tradeNo := strings.TrimSpace(fmt.Sprint(claims["appAccountToken"]))
transactionID := strings.TrimSpace(fmt.Sprint(claims["transactionId"]))
if tradeNo == "" || tradeNo == "<nil>" || transactionID == "" || transactionID == "<nil>" {
return nil, errors.New("Apple 回调缺少 appAccountToken 或 transactionId")
}
if _, parseErr := uuid.Parse(tradeNo); parseErr != nil {
return nil, errors.New("Apple 回调 appAccountToken 不是有效 UUID")
}
return &biz.PaymentResult{Provider: biz.PaymentApple, Status: "pending", TradeNo: tradeNo, ProviderTradeNo: transactionID, QueryID: transactionID, Payload: payload}, nil
}
func appleClaims(value any) (map[string]any, error) {
payload, err := json.Marshal(value)
if err != nil {
return nil, err
}
claims := map[string]any{}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, err
}
return claims, nil
}
func applePaymentResult(transactionID string, claims map[string]any, payload []byte, c map[string]any) (*biz.PaymentResult, error) {
revoked, err := appleRevoked(claims)
if err != nil {
return nil, err
}
status := "success"
if revoked {
status = "failed"
}
providerTradeNo := strings.TrimSpace(fmt.Sprint(claims["transactionId"]))
if providerTradeNo == "" || providerTradeNo == "<nil>" || providerTradeNo != transactionID {
return nil, errors.New("Apple 查询返回的 transactionId 不匹配")
}
tradeNo := strings.TrimSpace(fmt.Sprint(claims["appAccountToken"]))
if tradeNo == "" || tradeNo == "<nil>" {
return nil, errors.New("Apple 交易缺少 appAccountToken")
}
if _, err := uuid.Parse(tradeNo); err != nil {
return nil, errors.New("Apple 交易 appAccountToken 不是有效 UUID")
}
result := &biz.PaymentResult{Provider: biz.PaymentApple, Status: status, TradeNo: tradeNo, ProviderTradeNo: providerTradeNo, QueryID: providerTradeNo, Payload: payload}
if status != "success" {
return result, nil
}
result.Currency = strings.ToUpper(strings.TrimSpace(fmt.Sprint(claims["currency"])))
if result.Currency == "<nil>" {
result.Currency = ""
}
if result.Currency == "" {
return nil, errors.New("Apple 交易缺少 currency")
}
price, err := parseIntegerAmount(strings.TrimSpace(fmt.Sprint(claims["price"])))
if err != nil {
return nil, fmt.Errorf("解析 Apple 交易 price: %w", err)
}
divisor := applePriceDivisor(c, result.Currency)
if divisor <= 0 || price%divisor != 0 {
return nil, errors.New("Apple price_divisor 未配置或交易金额无法精确换算到最小货币单位")
}
result.Amount = price / divisor
result.PayerPaidAmount = result.Amount
result.CashPaidAmount = result.Amount
result.SettlementAmount = result.Amount
result.PayerCurrency = result.Currency
result.AmountBreakdownKnown = true
return result, nil
}
func appleRevoked(claims map[string]any) (bool, error) {
value, exists := claims["revocationDate"]
if !exists || value == nil {
return false, nil
}
switch number := value.(type) {
case float64:
if math.IsNaN(number) || math.IsInf(number, 0) || number < 0 || math.Trunc(number) != number {
return false, errors.New("Apple revocationDate 无效")
}
return number > 0, nil
case json.Number:
parsed, err := number.Int64()
if err != nil || parsed < 0 {
return false, errors.New("Apple revocationDate 无效")
}
return parsed > 0, nil
case int64:
if number < 0 {
return false, errors.New("Apple revocationDate 无效")
}
return number > 0, nil
case int:
if number < 0 {
return false, errors.New("Apple revocationDate 无效")
}
return number > 0, nil
}
textValue := strings.TrimSpace(fmt.Sprint(value))
if textValue == "" || textValue == "<nil>" || textValue == "0" {
return false, nil
}
parsed, err := parseIntegerAmount(textValue)
if err != nil {
return false, fmt.Errorf("解析 Apple revocationDate: %w", err)
}
return parsed > 0, nil
}
func applePriceDivisor(c map[string]any, currency string) int64 {
if values, ok := c["price_divisors"].(map[string]any); ok {
for key, value := range values {
if strings.EqualFold(strings.TrimSpace(key), currency) {
return configuredInt64(map[string]any{"value": value}, "value", 0)
}
}
}
return configuredInt64(c, "price_divisor", 0)
}
func validateAppleEnvironment(claims map[string]any, c map[string]any) error {
actual := strings.ToLower(strings.TrimSpace(fmt.Sprint(claims["environment"])))
if actual == "" || actual == "<nil>" {
return errors.New("Apple 交易缺少 environment")
}
expected := "production"
if strings.EqualFold(text(c, "environment"), "sandbox") {
expected = "sandbox"
}
if actual != expected {
return errors.New("Apple 交易 environment 不匹配")
}
return nil
}