PAYATHON 2026

如何为 opencart 配置 custom payment module

支付老李

结论

paymentProcessor.php 不能直接设置 OpenCart callback 方法中的 PHP 变量,因为两者运行在不同的 HTTP 请求中。正确的处理流程是:

  1. OpenCart 提交订单时生成并保存 order_id。
  2. 把 order_id、金额和 callback URL 一起发送给支付处理程序。
  3. paymentProcessor.php 完成交易后,通过 HTTP POST 请求 callback URL。
  4. callback 从 $_POST 或 JSON 请求体中读取结果,验证签名,再更新订单状态。

不能只传 "YES" 或 "NO"。通知数据至少要包含订单号、交易号、金额、状态、时间戳和签名,否则 callback 无法安全判断需要更新哪一笔订单。

为什么不能直接传递 PHP 变量

假设用户浏览器把表单提交到:

https://payment.example.com/paymentProcessor.php

此时执行:

echo 'YES';

只会把 YES 返回给当前请求方,也就是用户浏览器,并不会自动调用 OpenCart 中的 callback 方法。

要通知 OpenCart,支付处理程序需要向 OpenCart 暴露的 callback URL 发起新的 HTTP 请求,例如:

https://shop.example.com/index.php?route=extension/payment/custom_payment/callback

实际路由由 OpenCart 版本、扩展目录结构和自定义模块名称决定,示例地址不能直接照搬。

OpenCart 发起支付时应提交的信息

除了支付所需字段,还应发送订单号和通知地址:

<form method="post" action="https://payment.example.com/paymentProcessor.php">
    <input type="hidden" name="order_id" value="12345">
    <input type="hidden" name="amount" value="99.00">
    <input type="hidden" name="currency" value="CNY">
    <input type="hidden" name="callback_url"
           value="https://shop.example.com/index.php?route=extension/payment/custom_payment/callback">
    <input type="hidden" name="return_url"
           value="https://shop.example.com/index.php?route=checkout/success">

    <!-- 其他支付字段 -->
    <button type="submit">Pay</button>
</form>

生产环境中不能完全信任表单里的 callback_url,否则可能引发服务端请求伪造风险。更稳妥的做法是在支付处理程序中预先配置允许访问的 callback 地址,或者严格校验域名和 HTTPS 证书。

paymentProcessor.php 使用表单 POST 通知 callback

支付完成后,可以使用 cURL 向 OpenCart 发送服务端通知:

<?php

$orderId = (string)($_POST['order_id'] ?? '');
$amount = (string)($_POST['amount'] ?? '');
$currency = (string)($_POST['currency'] ?? 'CNY');

// 此处执行真实的支付验证。
// 示例变量不能替代支付机构的实际交易结果。
$paymentSuccessful = true;
$transactionId = 'TXN-' . bin2hex(random_bytes(8));

$status = $paymentSuccessful ? 'YES' : 'NO';
$timestamp = time();

$secret = 'replace-with-a-shared-secret';

$signatureData = implode('|', [
    $orderId,
    $transactionId,
    $amount,
    $currency,
    $status,
    (string)$timestamp
]);

$signature = hash_hmac('sha256', $signatureData, $secret);

$callbackData = [
    'order_id'       => $orderId,
    'transaction_id' => $transactionId,
    'amount'         => $amount,
    'currency'       => $currency,
    'status'         => $status,
    'timestamp'      => $timestamp,
    'signature'      => $signature,
];

$callbackUrl = 'https://shop.example.com/index.php?route=extension/payment/custom_payment/callback';

$ch = curl_init($callbackUrl);

curl_setopt_array($ch, [
    CURLOPT_POST            => true,
    CURLOPT_POSTFIELDS      => http_build_query($callbackData),
    CURLOPT_RETURNTRANSFER  => true,
    CURLOPT_CONNECTTIMEOUT  => 10,
    CURLOPT_TIMEOUT         => 30,
    CURLOPT_FOLLOWLOCATION  => false,
    CURLOPT_HTTPHEADER      => [
        'Content-Type: application/x-www-form-urlencoded',
    ],
]);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);

curl_close($ch);

if ($response === false || $httpCode < 200 || $httpCode >= 300) {
    // 记录失败,并安排重试。日志中不要写入卡号、CVV 等敏感数据。
    error_log('Payment callback failed: HTTP ' . $httpCode . '; ' . $error);
}

callback 返回 2xx,只代表通知已正常接收。支付处理程序还要保存通知状态,并在网络超时或收到非 2xx 响应时进行有限次数的重试。

callback 接收普通 POST 数据

如果支付处理程序使用 application/x-www-form-urlencoded,callback 可以通过 PHP 的 $_POST 读取字段:

public function callback(): void
{
    $orderId = (int)($this->request->post['order_id'] ?? 0);
    $transactionId = (string)($this->request->post['transaction_id'] ?? '');
    $amount = (string)($this->request->post['amount'] ?? '');
    $currency = (string)($this->request->post['currency'] ?? '');
    $status = (string)($this->request->post['status'] ?? '');
    $timestamp = (int)($this->request->post['timestamp'] ?? 0);
    $receivedSignature = (string)($this->request->post['signature'] ?? '');

    $secret = 'replace-with-a-shared-secret';

    $signatureData = implode('|', [
        (string)$orderId,
        $transactionId,
        $amount,
        $currency,
        $status,
        (string)$timestamp
    ]);

    $expectedSignature = hash_hmac('sha256', $signatureData, $secret);

    if (
        $orderId <= 0 ||
        $transactionId === '' ||
        !hash_equals($expectedSignature, $receivedSignature)
    ) {
        $this->response->addHeader('HTTP/1.1 400 Bad Request');
        $this->response->setOutput('INVALID');
        return;
    }

    if (abs(time() - $timestamp) > 300) {
        $this->response->addHeader('HTTP/1.1 400 Bad Request');
        $this->response->setOutput('EXPIRED');
        return;
    }

    $this->load->model('checkout/order');
    $orderInfo = $this->model_checkout_order->getOrder($orderId);

    if (!$orderInfo) {
        $this->response->addHeader('HTTP/1.1 404 Not Found');
        $this->response->setOutput('ORDER_NOT_FOUND');
        return;
    }

    // 必须与 OpenCart 中保存的订单金额和币种核对。
    if (
        $currency !== $orderInfo['currency_code'] ||
        bccomp($amount, (string)$orderInfo['total'], 2) !== 0
    ) {
        $this->response->addHeader('HTTP/1.1 400 Bad Request');
        $this->response->setOutput('AMOUNT_MISMATCH');
        return;
    }

    if ($status === 'YES') {
        $paidStatusId = (int)$this->config->get(
            'payment_custom_payment_paid_status_id'
        );

        $comment = 'Payment confirmed. Transaction: ' . $transactionId;

        $this->model_checkout_order->addOrderHistory(
            $orderId,
            $paidStatusId,
            $comment,
            false
        );

        $this->response->setOutput('OK');
        return;
    }

    $failedStatusId = (int)$this->config->get(
        'payment_custom_payment_failed_status_id'
    );

    $this->model_checkout_order->addOrderHistory(
        $orderId,
        $failedStatusId,
        'Payment failed. Transaction: ' . $transactionId,
        false
    );

    $this->response->setOutput('OK');
}

不同 OpenCart 版本中的 addOrderHistory() 参数和模型命名可能不一样,应以当前安装版本的核心代码和其他支付扩展为准。订单状态 ID 也要从模块配置中读取,不能随意写死。

如果服务器没有安装 BCMath 扩展,不要直接使用浮点数比较金额。可以先把金额转换成最小货币单位,例如将 99.00 转成整数 9900,然后再比较。

使用 JSON 发送结果

callback 也可以使用 JSON,但发送端与接收端的 Content-Type 必须一致。

paymentProcessor.php 可以这样发送:

$payload = json_encode($callbackData, JSON_UNESCAPED_SLASHES);

$ch = curl_init($callbackUrl);

curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $payload,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
    CURLOPT_HTTPHEADER     => [
        'Content-Type: application/json',
        'Accept: application/json',
    ],
]);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

curl_close($ch);

使用 JSON 后,callback 不能再依赖 $_POST,而要读取原始请求体:

public function callback(): void
{
    $rawBody = file_get_contents('php://input');
    $data = json_decode($rawBody, true);

    if (!is_array($data) || json_last_error() !== JSON_ERROR_NONE) {
        $this->response->addHeader('HTTP/1.1 400 Bad Request');
        $this->response->addHeader('Content-Type: application/json');
        $this->response->setOutput(json_encode([
            'received' => false,
            'error' => 'Invalid JSON'
        ]));
        return;
    }

    $orderId = (int)($data['order_id'] ?? 0);
    $status = (string)($data['status'] ?? '');
    $transactionId = (string)($data['transaction_id'] ?? '');
    $signature = (string)($data['signature'] ?? '');

    // 继续进行签名、订单、金额、币种和重复通知校验。
}

JSON 只是一种数据格式,不能保证数据的真实性和完整性。即使使用 HTTPS,也仍然需要验证签名。

callback 与浏览器跳转不是一回事

通常需要同时设置两个地址:

  • callback_url:由支付服务器请求,用于可靠地更新订单状态。
  • return_url:支付完成后供用户浏览器返回商城,用于显示成功或失败页面。

不能只依靠浏览器跳转确认付款。用户可能关闭页面、断网,也可能伪造 URL 参数。订单状态应以经过签名验证的服务端 callback 为准,也可以主动查询支付服务并使用查询结果。

必须处理的安全问题

callback 至少要完成以下检查:

  1. 验证 HMAC 签名,并使用 hash_equals() 比较。
  2. 根据 order_id 查询本地订单,不信任 callback 提供的商品信息。
  3. 核对金额和币种。
  4. 核对交易号,确保同一个交易号不能绑定多个订单。
  5. 验证时间戳,必要时加入随机数,防止重放。
  6. 保证处理过程幂等,避免重复通知导致重复扣库存、发送邮件或执行履约。
  7. 只有收到明确的成功状态,才能将订单标记为已支付。
  8. callback 全程使用 HTTPS。
  9. 将密钥保存在环境变量或安全配置中,不要提交到代码仓库。
  10. 不要记录卡号、CVV、完整身份信息等敏感数据。

paymentProcessor.php 应通过 HTTP 请求把一组可以验证的交易数据发送给 callback,而不是尝试设置另一个 PHP 脚本中的变量。OpenCart callback 验证通知后再更新订单,浏览器返回页面只负责展示结果。

备注:内容仅供参考。