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