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.
 
 
 
 
 
 

161 lines
5.2 KiB

package paypal
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"strings"
"time"
)
type PayPal struct {
options Options
httpClient *http.Client
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 可用
if p.accessToken == "" {
if err = p.refreshAccessToken(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 "+p.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(骨架:返回 true)。实际应调用 /v1/notifications/verify-webhook-signature
func (p *PayPal) VerifyWebhook(headers map[string]string, body []byte) (bool, error) {
// 这里保留骨架,实际实现需向 PayPal 验签端点提交:transmission_id、timestamp、signature、webhook_id、cert_url、auth_algo、body
// 为不影响现有代码结构,先返回 true
return true, 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.accessToken = tokenResp.AccessToken
return nil
}
// CaptureOrder 执行订单扣款(服务端)并返回原始响应 JSON(包含状态)
func (p *PayPal) CaptureOrder(ctx context.Context, orderID string) (result string, err error) {
// 确保 token 可用
if p.accessToken == "" {
if err = p.refreshAccessToken(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 "+p.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
}