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 }