TypechoJoeTheme

Dcr163的博客

统计

Thinkphp6接入Paypal支付流程

2025-10-09
/
0 评论
/
148 阅读
/
正在检测是否收录...
10/09

直接进入主题

这里直接在默认的Index控制器中编写的测试代码

<?php
/**
 * Created by PhpStorm.
 * Author: Dcr163
 * Date: 2025/10/10
 * Time: 15:49
 * paypal服务类
 */

namespace app\customer\services;

use Exception;
use think\App;
use think\facade\Log;

class PaypalService
{

    //paypal 沙盒环境
    const PAYPAL_URL = 'https://api-m.sandbox.paypal.com';
    //paypal 正式环境
    // const PAYPAL_URL = 'https://api-m.paypal.com';

    //获取Token 链接 沙盒环境
    const GET_TOKEN_URL = self::PAYPAL_URL . '/v1/oauth2/token';
    //订单创建URL
    const CREATE_ORDER_URL = self::PAYPAL_URL . '/v2/checkout/orders';
    //get:显示订单详情 patch:更新状态为“已创建”或“已批准”的订单。无法更新状态为“已完成”的订单 /v2/checkout/orders/{id}
    const SHOW_ORDER_URL = self::PAYPAL_URL . '/v2/checkout/orders/';

    //API KEY   这里面对应https://developer.paypal.com/dashboard/applications/sandbox
    private $clientId;
    //API秘钥   这里面对应https://developer.paypal.com/dashboard/applications/sandbox
    private $clientSecret;

    //paypal通信token
    public $paypalToken = '';
    //paypal auth token缓存key
    const TOKEN_KEY = 'paypal:auth:token';

    //webhook值 https://developer.paypal.com/dashboard/applications app管理底部webhook板块
    private $webhookId;
    //webhook 验证证书
    private $certCache = [];
    //webhook验证证书保存地址
    private $cacheDir = '';

    public function __construct(array $config)
    {
        $this->clientId = $config['client_id'] ?? '';
        $this->clientSecret = $config['client_secret'] ?? '';
    }


    /**
     * 获取paypal通信 Token
     * @param bool $refresh 是否强制刷新token
     * @return void
     * @throws Exception
     */
    public function getToken(bool $refresh = false)
    {
        //先读取缓存
        $token = redisUser(14)->get(self::TOKEN_KEY);
        if (!$token || $refresh) {
            $res = $this->request(self::GET_TOKEN_URL, 'POST', http_build_query(['grant_type' => 'client_credentials']), '', 10, '', [$this->clientId, $this->clientSecret]);
            $data = json_decode($res['content'], true);
            if (isset($data['error']) || !isset($data['access_token'])) {
                throw new Exception($data);
            }
            //把token给缓存下来,设置过期时间早与paypal过期时间
            redisUser(14)->setex(self::TOKEN_KEY, $data['expires_in'] - 10, $data['access_token']);
            $token = $data['access_token'];
        }
        $this->paypalToken = $token;
    }

    /**
     * 向paypal下单
     * @param array $order 订单信息
     * @param string $returnUrl 支付成功跳转地址
     * @param string $cancelUrl 支付取消跳转地址
     * @return array
     * @throws Exception
     */
    public function orderCreate(array $order, string $returnUrl = '', string $cancelUrl = '')
    {
        $this->getToken();
        //paypal接受的下单
        $orderData = [
            'purchase_units' => [
                [
                    //API 调用者提供的购买单位外部 ID  最长256字符
                    'reference_id' => $order['order_sn'],
                    //购买描述 不超过3000字符
                    'description' => $order['order_sn'],
                    //API 调用者提供的外部 ID
                    'custom_id' => $order['order_sn'],
                    'amount' => [
                        //订单所属货币:CNY、USD 这种格式
                        'currency_code' => $order['currency_name'],
                        //订单总付款金额
                        'value' => $order['customer_paid_amount'],
                    ],
                ]
            ],
            'intent' => 'CAPTURE', //即时到账
            'payment_source' => [
                'paypal' => [
                    'experience_context' => [
                        'payment_method_preference' => 'IMMEDIATE_PAYMENT_REQUIRED',
                        //LOGIN:当客户点击 PayPal Checkout 时,客户将被重定向到一个页面以登录 PayPal 并批准付款 ; GUEST_CHECKOUT:当客户点击 PayPal 结账时,系统会将客户重定向至一个页面,要求输入信用卡或借记卡信息以及其他完成购买所需的相关账单信息。此选项之前也称为“账单”;NO_PREFERENCE:当客户点击 PayPal Checkout 时,客户将被重定向到登录 PayPal 并批准付款的页面,或者输入信用卡或借记卡以及完成购买所需的其他相关账单信息的页面,具体取决于他们之前与 PayPal 的互动。
                        'landing_page' => 'NO_PREFERENCE',
                        //GET_FROM_FILE:在 PayPal 网站上获取客户提供的送货地址; NO_SHIPPING:从 API 响应和 Paypal 网站中删除收货地址信息; SET_PROVIDED_ADDRESS:获取商家提供的地址。客户无法在 PayPal 网站上更改此地址。如果商家未提供地址,客户可以在 PayPal 页面上选择地址
                        "shipping_preference" => "NO_SHIPPING",
                        "user_action" => "PAY_NOW",
                        "return_url" => $returnUrl,  //批准付款后跳转的商家地址
                        "cancel_url" => $cancelUrl  //取消付款后跳转的商家地址
                    ],
                ]
            ],
        ];
        //请求需要的格式
        $header = [
            'Authorization:Bearer ' . $this->paypalToken,
            'Content-Type:application/json',
        ];
        $res = $this->request(self::CREATE_ORDER_URL, 'POST', json_encode($orderData), $header);
        $content = json_decode($res['content'], true);
        if (!in_array($res['http_code'], [200, 201])) {
            $this->logError($res);
            throw new Exception(json_encode($res));
        }
        //前端需要跳转的支付地址
        $redirectUrl = '';
        //获取操作支付的地址
        foreach ($content['links'] as $link) {
            //payer-action 获取支付的URL
            if ($link['rel'] == 'payer-action') {
                $redirectUrl = $link['href'];
                break;
            }
        }
        //返回前端
        return [
            'id' => $content['id'],
            'paypal_id' => $content['id'],
            'status' => $content['status'],
            'redirect_url' => $redirectUrl,
        ];
    }

    /**
     * 查询paypal订单详情
     * @param string $paypalId
     * @return mixed
     * @throws Exception
     */
    public function orderShow(string $paypalId)
    {
        $this->getToken();
        $res = $this->request(self::SHOW_ORDER_URL . $paypalId, 'get', [], ['Authorization:Bearer ' . $this->paypalToken]);
        $content = json_decode($res['content'], true);
        if (!in_array($res['http_code'], [200, 201])) {
            $this->logError($res);
            throw new Exception(json_encode($res));
        }
        return $content;
    }

    /**
     * 捕获支付订单,这里可以修改订单状态,同步capture_id到订单表
     * @param string $paypalId
     * @return mixed
     * @throws Exception
     */
    public function orderCapture(string $paypalId)
    {
        $this->getToken();
        $header = [
            'Authorization:Bearer ' . $this->paypalToken,
            'Content-Type:application/json',
        ];
        $res = $this->request(self::SHOW_ORDER_URL . $paypalId . '/capture', 'post', [], $header);
        $content = json_decode($res['content'], true);
        if (!in_array($res['http_code'], [200, 201])) {
            $this->logError($res);
            throw new Exception(json_encode($res));
        }
        return $content;
    }

    /**
     * 支付订单退款
     * @param string $captureId 捕获订单的ID
     * @param string $currencyCode 退款的货币:CNY、USD 这种格式
     * @param string $money 退款的金额
     * @return mixed
     * @throws Exception
     */
    public function orderRefund(string $captureId, string $currencyCode, string $money)
    {
        $this->getToken();
        $refundUrl = self::PAYPAL_URL . '/v2/payments/captures/' . $captureId . '/refund';
        $header = [
            'Authorization:Bearer ' . $this->paypalToken,
            'Content-Type:application/json',
        ];
        $params = [
            'amount' => [
                'currency_code' => $currencyCode,
                'value' => $money,
            ]
        ];
        $res = $this->request($refundUrl, 'post', json_encode($params), $header);
        $content = json_decode($res['content'], true);
        if (!in_array($res['http_code'], [200, 201])) {
            $this->logError($res);
            throw new Exception(json_encode($res));
        }
        return $content;
    }


    /**
     * 发起CURL请求
     * @param string $url
     * @param string $method
     * @param $data
     * @param $header
     * @param int $timeout
     * @param $cookie
     * @param array $authInfo
     * @return array
     */
    private function request(string $url, string $method = 'get', $data = [], $header = false, int $timeout = 10, $cookie = '', array $authInfo = [])
    {
        $curl = curl_init($url);
        $method = strtoupper($method);
        //请求方式
        curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
        //携带参数
        if (in_array($method, ['POST', 'PATCH'])) {
            curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
        } elseif ($method == 'GET' && count($data)) {
            $url .= '?' . http_build_query($data);
            curl_setopt($curl, CURLOPT_URL, $url);
        }
        //超时时间
        curl_setopt($curl, CURLOPT_TIMEOUT, $timeout);
        //设置header头
        if ($header) curl_setopt($curl, CURLOPT_HTTPHEADER, $header);

        curl_setopt($curl, CURLOPT_FAILONERROR, false);
        //返回抓取数据
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
        //输出header头信息
        curl_setopt($curl, CURLOPT_HEADER, true);
        //TRUE 时追踪句柄的请求字符串,从 PHP 5.1.3 开始可用。这个很关键,就是允许你查看请求header
        curl_setopt($curl, CURLINFO_HEADER_OUT, true);
        //https请求
        if (1 == strpos("$" . $url, "https://")) {
            curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
            curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, false);
        }
        //设置cookie
        if ($cookie != '') curl_setopt($curl, CURLOPT_COOKIE, $cookie);
        //设置认证信息
        if (!empty($authInfo)) curl_setopt($curl, CURLOPT_USERPWD, implode(':', $authInfo));
        $curlError = curl_error($curl);

        list($content, $status) = [curl_exec($curl), curl_getinfo($curl), curl_close($curl)];
        $content = trim(substr($content, $status['header_size']));
        return [
            'error' => $curlError,
            'http_code' => $status['http_code'],
            'content' => $content,
        ];
    }

    ##===============下面是 webhook 异步回调用的函数===============##

    /**
     * 初始化webhook
     * @param string $webhookId
     * @return void
     */
    public function initWebhook(string $webhookId)
    {
        $this->webhookId = $webhookId;
        $this->cacheDir = app()->getRuntimePath() . 'log/paypal/';
        // 确保缓存目录存在
        if (!is_dir($this->cacheDir)) {
            mkdir($this->cacheDir, 0755, true);
        }
    }

    /**
     * 验证 PayPal Webhook 签名
     */
    public function verifySignature($rawRequestBody, $headers)
    {
        try {
            // 获取必要的头信息
            $transmissionId = $this->getHeader($headers, 'Paypal-Transmission-Id');
            $transmissionTime = $this->getHeader($headers, 'Paypal-Transmission-Time');
            $transmissionSig = $this->getHeader($headers, 'Paypal-Transmission-Sig');
            $certUrl = $this->getHeader($headers, 'Paypal-Cert-Url');
            $authAlgo = $this->getHeader($headers, 'Paypal-Auth-Algo', 'SHA256withRSA');

            // 验证必要的头信息存在
            if (!$transmissionId || !$transmissionTime || !$transmissionSig || !$certUrl) {
                $this->log("Missing required PayPal webhook headers");
            }

            //  计算 CRC32(使用 crc32b,PHP 默认)
            $crc32 = hash('crc32b', $rawRequestBody);
            // 转为十进制整数
            $crc32_decimal = hexdec($crc32);
            // 构建验证消息
            $message = sprintf('%s|%s|%s|%s',
                $transmissionId,
                $transmissionTime,
                $this->webhookId,
                $crc32_decimal
            );

            $this->logError("Verification message: {$message}");

            // 获取证书
            $certificate = $this->downloadAndCacheCertificate($certUrl);

            // 解码签名
            $signature = base64_decode($transmissionSig);

            // 验证签名
            return $this->verifyRSASignature($message, $signature, $certificate, $authAlgo);

        } catch (Exception $e) {
            $this->logError("PayPal webhook verification failed: " . $e->getMessage());
            return false;
        }
    }

    /**
     * 验证 RSA 签名
     */
    private function verifyRSASignature($message, $signature, $certificate, $authAlgo)
    {
        // 从证书中提取公钥
        $publicKey = openssl_pkey_get_public($certificate);
        if ($publicKey === false) {
            $this->log('Failed to extract public key from certificate: ' . openssl_error_string());
        }

        // 根据算法确定 OpenSSL 算法常量
        $algorithm = $this->getOpenSSLAlgorithm($authAlgo);

        // 验证签名
        $result = openssl_verify($message, $signature, $publicKey, $algorithm);

        // 释放资源
        openssl_free_key($publicKey);

        if ($result === 1) {
            return true;
        } elseif ($result === 0) {
            $this->logError('Signature verification failed: Invalid signature');
        } else {
            $this->logError('Signature verification error: ' . openssl_error_string());
        }
    }

    /**
     * 将 PayPal 算法名称转换为 OpenSSL 常量
     */
    private function getOpenSSLAlgorithm($authAlgo)
    {
        $mapping = [
            'SHA256withRSA' => OPENSSL_ALGO_SHA256,
            'SHA1withRSA' => OPENSSL_ALGO_SHA1,
            'SHA384withRSA' => OPENSSL_ALGO_SHA384,
            'SHA512withRSA' => OPENSSL_ALGO_SHA512,
            'MD5withRSA' => OPENSSL_ALGO_MD5,
        ];

        return $mapping[$authAlgo] ?? OPENSSL_ALGO_SHA256;
    }

    /**
     * 下载并缓存证书
     */
    private function downloadAndCacheCertificate($certUrl)
    {
        // 检查缓存
        $cacheKey = md5($certUrl);
        $cacheFile = $this->cacheDir . '/' . $cacheKey . '.pem';

        if (isset($this->certCache[$cacheKey])) {
            return $this->certCache[$cacheKey];
        }

        if (file_exists($cacheFile) && time() - filemtime($cacheFile) < 3600) {
            // 使用缓存的证书(1小时内有效)
            $certificate = file_get_contents($cacheFile);
            $this->certCache[$cacheKey] = $certificate;
            return $certificate;
        }

        // 下载新证书
        $context = stream_context_create([
            'ssl' => [
                'verify_peer' => true,
                'verify_peer_name' => true,
                'allow_self_signed' => false,
            ],
            'http' => [
                'timeout' => 10,
                'user_agent' => 'PayPal-Webhook-Verifier/1.0'
            ]
        ]);

        $certificate = file_get_contents($certUrl, false, $context);
        if ($certificate === false) {
            $this->logError("Failed to download certificate from: {$certUrl}");
        }

        // 验证证书格式
        if (!openssl_x509_read($certificate)) {
            $this->logError('Invalid certificate format: ' . openssl_error_string());
        }

        // 缓存证书
        file_put_contents($cacheFile, $certificate);
        $this->certCache[$cacheKey] = $certificate;

        return $certificate;
    }

    /**
     * 安全获取头信息
     */
    private function getHeader($headers, $key, $default = null)
    {
        if (isset($headers[$key])) {
            return $headers[$key];
        }
        $key = 'HTTP_' . $key;
        if (isset($headers[$key])) {
            return $headers[$key];
        }
        return $default;
    }

    /**
     * 清理所有证书缓存
     */
    public function clearCache()
    {
        $files = glob($this->cacheDir . '*.pem');
        foreach ($files as $file) {
            if (is_file($file)) {
                unlink($file);
            }
        }
        $this->certCache = [];
    }

    /**
     * 记录日志
     * @param $data
     * @return void
     */
    public function log($data)
    {
        Log::channel('paypal')->info($data);
    }

    /**
     * 记录错误信息
     * @param $data
     * @return void
     */
    public function logError($data)
    {
        Log::channel('paypal')->error($data);
    }
}

整理了一个简易的流程图

paypalpaypal接入paypal支付
朗读
赞(1)
版权属于:

Dcr163的博客

本文链接:

http://dcr163.cn/763.html(转载时请注明本文出处及文章链接)

评论 (0)

人生倒计时

今日已经过去小时
这周已经过去
本月已经过去
今年已经过去个月

最新回复

  1. JetBlack
    2025-10-05
  2. Emmajop
    2025-09-28
  3. 噢噢噢
    2025-09-24
  4. Labeling Machine
    2025-09-22
  5. http://goldenfiber.ru/forum/?PAGE_NAME=profile_view&UID=55151&backurl=%2Fforum%2F%3FPAGE_NAME%3Dprofile_view%26UID%3D32514
    2025-07-10

标签云