You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

254 lines
8.5 KiB

package paypal
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"strings"
"sync"
"time"
)
// ErrWebhookNotConfigured 缺 ClientID/Secret/WebhookID,无法向 PayPal 验签。
// 此时 VerifyWebhook 一律判为「不可信」——没有验签能力就不能放行通知,否则谁都能伪造一笔支付来领资源。
var ErrWebhookNotConfigured = errors.New("paypal webhook 未配置:缺少 ClientID/Secret/WebhookID,无法验签")
type PayPal struct {
options Options
httpClient *http.Client
// accessToken 由多个请求共享(webhook 是并发入口),读写一律走 mu
mu sync.Mutex
accessToken string
}
func newSys(options Options) (sys *PayPal, err error) {
sys = &PayPal{options: options, httpClient: &http.Client{Timeout: 15 * time.Second}}
// 尝试获取访问令牌(Client Credentials)
_ = sys.refreshAccessToken(context.Background())
return
}
// 生成商户订单号(与其他支付系统一致)
func (p *PayPal) GenerateOrderNo(prefix string) string {
timeStr := time.Now().Format("20060102150405")
rand.Seed(time.Now().UnixNano())
randNum := rand.Intn(900000) + 100000
return fmt.Sprintf("%s%s%d", prefix, timeStr, randNum)
}
// CreateAppOrder 创建 PayPal 订单(APP 端:返回 orderID/approval 链接字符串)
// totalFee 单位为分,PayPal 需传金额单位为元的字符串
// 注意:重定向地址由 Options.ReturnURL / Options.CancelURL 提供(用于网页唤起 App)
func (p *PayPal) CreateAppOrder(ctx context.Context, outTradeNo string, totalFee int64, description string, return_url string, cancel_url string) (result string, err error) {
var (
resp *http.Response
)
// 确保 token 可用
var accessToken string
if accessToken, err = p.token(ctx); err != nil {
return
}
// 构造下单请求体(简化版)
amount := fmt.Sprintf("%.2f", float64(totalFee)/100)
reqBody := map[string]any{
"intent": "CAPTURE",
"purchase_units": []map[string]any{{
"reference_id": outTradeNo,
"custom_id": outTradeNo,
"description": description,
"amount": map[string]any{"currency_code": "USD", "value": amount},
}},
"application_context": func() map[string]any {
ctx := map[string]any{
"brand_name": "DeepServer",
"user_action": "PAY_NOW",
}
// 加入网页支付结果的重定向地址(可为 App Deep Link)
if return_url != "" {
ctx["return_url"] = return_url
}
if cancel_url != "" {
ctx["cancel_url"] = cancel_url
}
return ctx
}(),
}
bodyBytes, _ := json.Marshal(reqBody)
url := strings.TrimRight(p.options.BaseURL, "/") + "/v2/checkout/orders"
httpReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes)))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+accessToken)
resp, err = p.httpClient.Do(httpReq)
if err != nil {
return
}
defer resp.Body.Close()
var respJSON struct {
ID string `json:"id"`
Links []struct {
Href string `json:"href"`
Rel string `json:"rel"`
} `json:"links"`
}
if err = json.NewDecoder(resp.Body).Decode(&respJSON); err != nil {
return
}
// 返回 orderID 或 approve 链接作为前端唤起依据(这里返回 JSON 字符串,方便前端直接使用)
resultBytes, _ := json.Marshal(map[string]any{"order_id": respJSON.ID, "links": respJSON.Links})
result = string(resultBytes)
return
}
// VerifyWebhook 验证 PayPal Webhook 签名:把通知头 + 原始事件体交给 PayPal 的
// /v1/notifications/verify-webhook-signature 端点判定真伪,verification_status=SUCCESS 才算通过。
//
// 两个要点:
// - webhook_id 一律取**配置里的** WebhookID,绝不用请求头带来的——头是调用方给的,可以随便伪造;
// - webhook_event 必须是**原样的**通知体字节,重新序列化会改变字段顺序/空白,签名就对不上了。
//
// 未配置凭据时返回 ErrWebhookNotConfigured(判为不可信):没有验签能力就不该放行通知。
func (p *PayPal) VerifyWebhook(headers map[string]string, body []byte) (bool, error) {
if p.options.ClientID == "" || p.options.Secret == "" || p.options.WebhookID == "" {
return false, ErrWebhookNotConfigured
}
if len(body) == 0 {
return false, errors.New("paypal webhook 通知体为空")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
reqBody := map[string]any{
"auth_algo": headers["PayPal-Auth-Algo"],
"cert_url": headers["PayPal-Cert-Url"],
"transmission_id": headers["PayPal-Transmission-Id"],
"transmission_sig": headers["PayPal-Transmission-Sig"],
"transmission_time": headers["PayPal-Transmission-Time"],
"webhook_id": p.options.WebhookID,
"webhook_event": json.RawMessage(body),
}
payload, err := json.Marshal(reqBody)
if err != nil {
return false, err
}
// token 过期时 PayPal 回 401,刷一次再试;仍失败就判为未通过
for attempt := 0; attempt < 2; attempt++ {
accessToken, terr := p.token(ctx)
if terr != nil {
return false, terr
}
url := strings.TrimRight(p.options.BaseURL, "/") + "/v1/notifications/verify-webhook-signature"
httpReq, rerr := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload)))
if rerr != nil {
return false, rerr
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+accessToken)
resp, derr := p.httpClient.Do(httpReq)
if derr != nil {
return false, derr
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized && attempt == 0 {
p.mu.Lock()
p.accessToken = "" // 作废当前令牌,下一轮重新换
p.mu.Unlock()
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return false, fmt.Errorf("paypal 验签接口返回 %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var out struct {
VerificationStatus string `json:"verification_status"`
}
if err = json.Unmarshal(raw, &out); err != nil {
return false, err
}
if out.VerificationStatus != "SUCCESS" {
return false, fmt.Errorf("paypal 验签未通过: %s", out.VerificationStatus)
}
return true, nil
}
return false, errors.New("paypal 验签失败:访问令牌无效")
}
// token 取可用的访问令牌,没有就先换一个。并发安全(webhook 可能被 PayPal 并发推送)。
func (p *PayPal) token(ctx context.Context) (string, error) {
p.mu.Lock()
tk := p.accessToken
p.mu.Unlock()
if tk != "" {
return tk, nil
}
if err := p.refreshAccessToken(ctx); err != nil {
return "", err
}
p.mu.Lock()
tk = p.accessToken
p.mu.Unlock()
return tk, nil
}
// 刷新访问令牌(Client Credentials)
func (p *PayPal) refreshAccessToken(ctx context.Context) error {
if p.options.ClientID == "" || p.options.Secret == "" {
return nil // 未配置则跳过;允许骨架运行
}
url := strings.TrimRight(p.options.BaseURL, "/") + "/v1/oauth2/token"
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader("grant_type=client_credentials"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
basic := base64.StdEncoding.EncodeToString([]byte(p.options.ClientID + ":" + p.options.Secret))
req.Header.Set("Authorization", "Basic "+basic)
resp, err := p.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if err = json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return err
}
p.mu.Lock()
p.accessToken = tokenResp.AccessToken
p.mu.Unlock()
return nil
}
// CaptureOrder 执行订单扣款(服务端)并返回原始响应 JSON(包含状态)
func (p *PayPal) CaptureOrder(ctx context.Context, orderID string) (result string, err error) {
// 确保 token 可用
var accessToken string
if accessToken, err = p.token(ctx); err != nil {
return
}
url := strings.TrimRight(p.options.BaseURL, "/") + "/v2/checkout/orders/" + orderID + "/capture"
httpReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader("{}"))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := p.httpClient.Do(httpReq)
if err != nil {
return
}
defer resp.Body.Close()
var respMap map[string]any
if err = json.NewDecoder(resp.Body).Decode(&respMap); err != nil {
return
}
b, _ := json.Marshal(respMap)
result = string(b)
return
}