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
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
|
|
}
|
|
|