增加支付功能

This commit is contained in:
2026-09-15 12:46:29 +08:00
parent 943c3708b0
commit a82b300b1a
46 changed files with 11675 additions and 21 deletions
+244
View File
@@ -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
}