增加支付功能
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user