245 lines
6.9 KiB
Go
245 lines
6.9 KiB
Go
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
|
||
}
|