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 }