Files
2026-09-15 12:46:29 +08:00

245 lines
6.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package payment
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"server/models"
"github.com/plutov/paypal/v4"
)
// PayPalChannel PayPal(Checkout v2 + Webhook)
//
// 渠道参数(config_json):
//
// client_id REST App Client ID
// client_secret REST App Secret
// webhook_id Webhook ID(用于验签)
// currency 币种,默认 USD
//
// 扩展(extra_json):
//
// env sandbox / live
type PayPalChannel struct{}
func (c *PayPalChannel) Code() string { return ChannelPayPal }
func (c *PayPalChannel) Name() string { return "PayPal" }
func (c *PayPalChannel) buildClient(cfg *ChannelConfig) (*paypal.Client, error) {
clientID := cfg.Get("client_id")
secret := cfg.Get("client_secret")
if clientID == "" || secret == "" {
return nil, fmt.Errorf("PayPal 参数不完整:需要 client_id / client_secret")
}
apiBase := paypal.APIBaseSandBox
if strings.EqualFold(fmt.Sprint(cfg.Extra["env"]), "live") {
apiBase = paypal.APIBaseLive
}
client, err := paypal.NewClient(clientID, secret, apiBase)
if err != nil {
return nil, fmt.Errorf("创建 PayPal 客户端失败: %w", err)
}
return client, nil
}
func (c *PayPalChannel) currency(cfg *ChannelConfig) string {
if v := cfg.Get("currency"); v != "" {
return v
}
return "USD"
}
func (c *PayPalChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
client, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
subject := truncateStr(order.Subject, 120)
payOrder, err := client.CreateOrder(ctx, paypal.OrderIntentCapture,
[]paypal.PurchaseUnitRequest{{
CustomID: order.PayNo,
Description: subject,
Amount: &paypal.PurchaseUnitAmount{
Currency: c.currency(cfg),
Value: FenToYuan(order.Amount),
},
}},
nil,
&paypal.ApplicationContext{
ReturnURL: order.ReturnURL,
CancelURL: order.ReturnURL,
},
)
if err != nil {
return nil, fmt.Errorf("PayPal 下单失败: %w", err)
}
// v2 的跳转链接 rel 为 payer-action(旧版为 approve)
approve := ""
for _, l := range payOrder.Links {
if l.Rel == "approve" || l.Rel == "payer-action" {
approve = l.Href
break
}
}
if approve == "" {
return nil, fmt.Errorf("PayPal 下单未返回跳转链接")
}
return &PayParams{
Channel: ChannelPayPal,
PayType: PayTypeWeb,
RedirectURL: approve,
ChannelTradeNo: payOrder.ID, // PayPal 订单号,回调后替换为 capture id
}, nil
}
func (c *PayPalChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
client, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
if order.ChannelTradeNo == "" {
return nil, fmt.Errorf("PayPal 查询需要渠道订单号(尚未下单成功)")
}
payOrder, err := client.GetOrder(ctx, order.ChannelTradeNo)
if err != nil {
return nil, fmt.Errorf("PayPal 查询订单失败: %w", err)
}
state := &ChannelState{ChannelTradeNo: payOrder.ID, Raw: fmt.Sprintf("status=%s", payOrder.Status)}
switch payOrder.Status {
case "COMPLETED":
state.TradeState = StateSuccess
now := time.Now()
state.PaidAt = &now
case "VOIDED":
state.TradeState = StateClosed
case "APPROVED":
// 用户已批准但未捕获,等待 webhook 或人工捕获
state.TradeState = StatePending
default:
state.TradeState = StatePending
}
for _, pu := range payOrder.PurchaseUnits {
if pu.Amount != nil {
if fen, err := YuanToFen(pu.Amount.Value); err == nil {
state.Amount = fen
}
break
}
}
return state, nil
}
type paypalEvent struct {
ID string `json:"id"`
EventType string `json:"event_type"`
Resource struct {
ID string `json:"id"`
Status string `json:"status"`
CustomID string `json:"custom_id"`
Amount struct {
Value string `json:"value"`
CurrencyCode string `json:"currency_code"`
} `json:"amount"`
} `json:"resource"`
}
func (c *PayPalChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
client, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
webhookID := cfg.Get("webhook_id")
if webhookID == "" {
return nil, fmt.Errorf("PayPal 参数不完整:需要 webhook_id")
}
// VerifyWebhookSignature 会读取请求体,先读出来再回填,供后面解析事件内容
raw, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("读取 PayPal 通知失败: %w", err)
}
r.Body = io.NopCloser(bytes.NewReader(raw))
verify, err := client.VerifyWebhookSignature(ctx, r, webhookID)
if err != nil {
return nil, fmt.Errorf("PayPal 验签请求失败: %w", err)
}
if verify == nil || verify.VerificationStatus != "SUCCESS" {
return nil, fmt.Errorf("PayPal 通知验签失败: %s", verify.VerificationStatus)
}
event := &paypalEvent{}
if err := json.Unmarshal(raw, event); err != nil {
return nil, fmt.Errorf("解析 PayPal 事件失败: %w", err)
}
result := &NotifyResult{
EventType: event.EventType,
EventID: event.ID,
AckBody: "",
Raw: truncateStr(string(raw), 8192),
}
result.PayNo = event.Resource.CustomID
result.OutTradeNo = event.Resource.CustomID
result.ChannelTradeNo = event.Resource.ID
if fen, ferr := YuanToFen(event.Resource.Amount.Value); ferr == nil {
result.Amount = fen
}
switch event.EventType {
case "PAYMENT.CAPTURE.COMPLETED":
result.TradeState = StateSuccess
result.Paid = true
case "PAYMENT.CAPTURE.DENIED", "PAYMENT.CAPTURE.DECLINED":
result.TradeState = StateFailed
case "PAYMENT.CAPTURE.REFUNDED", "PAYMENT.CAPTURE.REVERSED":
result.TradeState = StateRefunded
default:
result.TradeState = StatePending
}
return result, nil
}
func (c *PayPalChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
client, err := c.buildClient(cfg)
if err != nil {
return "", err
}
if order.ChannelTradeNo == "" {
return "", fmt.Errorf("PayPal 退回需要 capture id(支付成功回调后写入),请先确认该单已收到成功回调")
}
resp, err := client.RefundCapture(ctx, order.ChannelTradeNo, paypal.RefundCaptureRequest{
Amount: &paypal.Money{
Currency: c.currency(cfg),
Value: FenToYuan(amount),
},
NoteToPayer: truncateStr(reason, 200),
})
if err != nil {
return "", fmt.Errorf("PayPal 退回请求失败: %w", err)
}
return resp.ID, nil
}
func (c *PayPalChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
client, err := c.buildClient(cfg)
if err != nil {
return "", err
}
token, err := client.GetAccessToken(ctx)
if err != nil {
return "", fmt.Errorf("PayPal OAuth2 凭证校验失败: %w", err)
}
if token == nil || token.Token == "" {
return "", fmt.Errorf("PayPal OAuth2 未返回访问令牌")
}
return fmt.Sprintf("连接成功:OAuth2 凭证校验通过(环境 %s)", cfg.Extra["env"]), nil
}