PAYATHON 2026

支持多个支付网关的 PHP Payment Library

支付阿杰

结论

可以。比较稳妥的做法是采用“统一支付接口 + 网关适配器”:业务代码只依赖统一接口,再通过配置选择 PayPal、Stripe 或其他支付网关。

如果想使用现成的 PHP 库,可以考虑 Omnipay。它为多个支付网关提供了相对统一的调用方式,也支持跳转到网关页面完成付款。不过,各网关对支付状态、退款、异步通知和参数的要求并不相同,因此很难做到“更换网关时完全不用改代码”。更实际的目标是保留订单业务代码,只修改配置或更换适配器。

推荐的支付流程

如果网站不希望接触或保存银行卡信息,优先选择托管支付页面:

  1. 网站创建本地订单并生成唯一订单号。
  2. 后端调用当前网关的“创建支付”接口。
  3. 用户跳转到支付网关的托管页面。
  4. 支付完成后,网关将用户重定向到 return_url。
  5. 网关通过 Webhook 或服务器回调发送支付结果。
  6. 网站验证回调签名,并主动查询支付状态。
  7. 确认付款成功后,将订单标记为已支付。

银行卡信息由支付网关采集和处理。网站通常只保存订单号、网关交易号、金额、币种和支付状态。

使用统一接口隔离网关差异

先定义业务实际需要的能力:

<?php

interface PaymentGatewayInterface
{
    public function createPayment(PaymentRequest $request): PaymentResponse;

    public function queryPayment(string $transactionId): PaymentStatus;

    public function verifyWebhook(
        string $payload,
        array $headers
    ): WebhookResult;
}

请求和响应使用自己的数据对象,避免业务层依赖某个网关 SDK 的类型:

<?php

final class PaymentRequest
{
    public function __construct(
        public readonly string $orderId,
        public readonly int $amountMinor,
        public readonly string $currency,
        public readonly string $returnUrl,
        public readonly string $cancelUrl
    ) {
    }
}

final class PaymentResponse
{
    public function __construct(
        public readonly string $transactionId,
        public readonly string $redirectUrl
    ) {
    }
}

金额建议使用最小货币单位。例如,人民币 1000 表示 10.00 CNY,这样可以避免用浮点数计算金额。

通过配置切换支付网关

配置文件只指定当前启用的网关:

<?php

return [
    'default' => getenv('PAYMENT_GATEWAY') ?: 'gateway_a',

    'gateways' => [
        'gateway_a' => [
            'api_key' => getenv('GATEWAY_A_API_KEY'),
            'webhook_secret' => getenv('GATEWAY_A_WEBHOOK_SECRET'),
        ],
        'gateway_b' => [
            'merchant_id' => getenv('GATEWAY_B_MERCHANT_ID'),
            'secret' => getenv('GATEWAY_B_SECRET'),
        ],
    ],
];

再通过工厂创建相应的适配器:

<?php

final class PaymentGatewayFactory
{
    public static function create(array $config): PaymentGatewayInterface
    {
        return match ($config['default']) {
            'gateway_a' => new GatewayAAdapter(
                $config['gateways']['gateway_a']
            ),
            'gateway_b' => new GatewayBAdapter(
                $config['gateways']['gateway_b']
            ),
            default => throw new InvalidArgumentException(
                'Unsupported payment gateway'
            ),
        };
    }
}

订单业务代码只需要调用统一接口:

<?php

$config = require __DIR__ . '/payment.php';
$gateway = PaymentGatewayFactory::create($config);

$request = new PaymentRequest(
    orderId: 'ORDER-20260901-001',
    amountMinor: 1000,
    currency: 'CNY',
    returnUrl: 'https://example.com/payment/return',
    cancelUrl: 'https://example.com/payment/cancel'
);

$response = $gateway->createPayment($request);

header('Location: ' . $response->redirectUrl, true, 302);
exit;

以后更换网关时,通常只需新增适配器并修改 PAYMENT_GATEWAY。订单、库存和发货代码不需要随之调整。

使用 Omnipay 时的基本思路

Omnipay 可以减少适配器层的重复代码,但目标网关对应的驱动需要单独安装。驱动名称、支持的功能和维护状态可能发生变化,使用前应检查其代码仓库和目标支付平台的最新文档。

典型的调用结构如下:

<?php

use Omnipay\Omnipay;

$gateway = Omnipay::create($configuredDriver);
$gateway->initialize($gatewayOptions);

$response = $gateway->purchase([
    'amount' => '10.00',
    'currency' => 'CNY',
    'transactionId' => 'ORDER-20260901-001',
    'returnUrl' => 'https://example.com/payment/return',
    'cancelUrl' => 'https://example.com/payment/cancel',
])->send();

if ($response->isRedirect()) {
    $response->redirect();
}

if ($response->isSuccessful()) {
    // 只有网关明确支持同步确认时,才能在这里处理成功状态。
}

throw new RuntimeException($response->getMessage());

这段代码只展示通用结构,不代表所有 Omnipay 驱动都接受完全相同的参数。有些网关还需要 completePurchase()、支付方式标识、客户信息或额外的签名参数,具体应以相应驱动和支付平台的文档为准。

回调处理不能只看浏览器跳转

用户进入支付成功页面,不代表付款已经成功。用户可能关闭页面,返回参数也可能被伪造。订单状态应以经过验证的服务器通知或主动查询结果为准:

<?php

$payload = file_get_contents('php://input');
$headers = function_exists('getallheaders') ? getallheaders() : [];

$result = $gateway->verifyWebhook($payload, $headers);

if (!$result->isValid()) {
    http_response_code(400);
    exit('Invalid signature');
}

$payment = $gateway->queryPayment($result->transactionId());

if ($payment->isPaid()) {
    // 使用数据库事务和唯一约束,确保同一订单只处理一次。
    markOrderAsPaid($payment);
}

http_response_code(200);
echo 'OK';

注意事项

  • 不要在日志、数据库或异常信息中保存完整卡号、CVV 或敏感认证数据。
  • API 密钥和 Webhook 密钥应存放在环境变量或密钥管理服务中,不要提交到代码仓库。
  • 必须验证 Webhook 签名,同时检查金额、币种、订单号和商户身份。
  • 回调处理必须具备幂等性。同一通知可能重复发送,不能因此重复扣减库存、发货或记账。
  • 创建支付请求时应设置唯一的幂等键,防止网络重试产生多笔交易。
  • 不要只凭 return_url 中的查询参数修改订单状态。
  • 保存网关的原始状态,并将其映射为内部状态,例如 pending、paid、failed、refunded。
  • 退款、部分退款、预授权、撤销和订阅支付之间差异较大,不适合强行放进一个过度简化的接口。
  • 托管支付页面可以显著缩小网站接触银行卡数据的范围,但不代表网站会自动免除所有 PCI DSS、安全或当地合规要求。具体责任需要根据支付集成方式和所在地区确认。
  • 选择第三方库时,除了查看支持的网关数量,还要检查维护活跃度、Webhook 支持、错误处理方式,以及目标网关的 API 是否已经升级。

如果项目只需要一次性支付和页面跳转,统一接口加适配器通常就够用了。网关较多时,可以在适配器内部使用 Omnipay。这样既能利用现成驱动,也能避免订单业务直接依赖第三方库。

备注:内容仅供参考。