PAYATHON 2026

OpenCart payment extension callback 无法工作

支付老李

结论

index.php?route=payment/hyperion/callback 要正常执行,控制器文件、控制器类名和路由名称必须完全匹配。当前代码还依赖 $this->session->data['order_id'],但支付网关回调通常不会带上用户原有的 OpenCart Session Cookie。即使路由配置正确,回调也可能找不到订单。

需要一并处理以下问题:

  1. 确保 payment/hyperion/callback 正确映射到 catalog/controller/payment/hyperion.php。
  2. 回调不能只通过 Session 获取 order_id。提交支付请求时,应把订单号放入商户参数,并让支付平台在回调中原样返回。
  3. 创建支付请求前调用 confirm()。否则订单可能仍处于 order_status_id = 0,默认不会显示在后台订单列表中。
  4. 回调收到请求后,先验证支付平台签名,再用合法的数字状态 ID 更新订单。
  5. 分开处理浏览器返回地址和支付平台的服务器通知地址。

检查 callback 路由

在较老版本的 OpenCart 中,下面的 URL:

index.php?route=payment/hyperion/callback

通常对应这个文件:

catalog/controller/payment/hyperion.php

文件中的类名必须是:

class ControllerPaymentHyperion extends Controller
{
    public function callback()
    {
        // ...
    }
}

以下三处名称必须一致:

项目正确值
路由payment/hyperion/callback
文件catalog/controller/payment/hyperion.php
类名ControllerPaymentHyperion

如果实际控制器文件名是 hyper.php,类名是 ControllerPaymentHyper,回调路由就应该写成:

index.php?route=payment/hyper/callback

代码使用 payment/hyper 语言文件和 hyper_success.tpl 模板本身没有问题,但这可能意味着模块曾在 hyper 和 hyperion 之间改过名称。需要检查控制器文件名、类名以及支付平台中配置的 URL,确认它们仍然使用同一个名称。

还要确认文件放在前台目录:

catalog/controller/payment/hyperion.php

而不是后台目录:

admin/controller/payment/hyperion.php

如果服务器区分大小写,文件名和类名的大小写也必须一致。

直接访问 callback 为什么会出现空白页

直接在浏览器中打开:

index.php?route=payment/hyperion/callback

请求中不会包含支付平台发送的 POST 数据,当前浏览器 Session 里也未必有:

$this->session->data['order_id']

原代码在确认订单是否存在前,就执行了:

$order_id = $order_info['order_id'];

如果 $order_info 是 false,这里可能触发 PHP 警告。后面的所有输出又都放在以下条件中:

if ($order_info) {
    // 所有输出都在这里
}

条件不成立时,控制器不会设置任何响应内容,浏览器看到的就是空白页。

至少应该先检查订单号和查询结果:

if (empty($this->session->data['order_id'])) {
    $this->response->setOutput('Missing order ID');
    return;
}

$order_id = (int)$this->session->data['order_id'];
$order_info = $this->model_checkout_order->getOrder($order_id);

if (!$order_info) {
    $this->response->setOutput('Order not found');
    return;
}

这只能让错误信息更清楚,无法解决支付平台回调丢失 Session 的根本问题。

回调不能依赖 Session

用户跳转到第三方支付网站后,再由支付网站发起跨站 POST 返回时,浏览器不一定会发送原来的 Session Cookie。服务器之间的异步通知更不会带有用户的浏览器 Session。

因此,下面这种写法并不可靠:

$order_id = $this->session->data['order_id'];

创建支付请求时,应该把订单号写入支付平台提供的商户参考字段,例如 merchant_reference、order_id 或 custom。实际使用哪个字段,必须以支付平台的接口文档为准:

$order_id = (int)$this->session->data['order_id'];

$payment_data = array(
    'merchant_reference' => $order_id,
    'callback_url'       => $this->url->link(
        'payment/hyperion/callback',
        '',
        'SSL'
    ),
    'return_url'         => $this->url->link(
        'payment/hyperion/returnPage',
        '',
        'SSL'
    )
);

收到回调后,再从支付平台返回的数据中读取订单号:

$order_id = isset($this->request->post['merchant_reference'])
    ? (int)$this->request->post['merchant_reference']
    : 0;

不能在收到未经验证的 order_id 后立即更新订单。代码还必须验证支付平台签名、交易号、金额和币种,以免攻击者伪造支付成功请求。

跳转支付平台前确认订单

旧版 OpenCart 可能会先以 order_status_id = 0 保存新订单。这类订单通常不会出现在后台的常规订单列表中。

生成支付表单并准备跳转到支付平台时,应先确认订单:

$order_id = (int)$this->session->data['order_id'];

$this->load->model('checkout/order');

$this->model_checkout_order->confirm(
    $order_id,
    (int)$this->config->get('config_order_status_id')
);

更合适的方式是配置一个“等待付款”状态,并在跳转前使用它:

$this->model_checkout_order->confirm(
    $order_id,
    (int)$this->config->get('hyper_pending_status_id')
);

这样,即使用户在支付平台关闭页面,订单也已经出现在后台,并保持“等待付款”状态。

不同 OpenCart 版本提供的订单接口并不完全相同。较新版本可能使用:

$this->model_checkout_order->addOrderHistory(
    $order_id,
    $order_status_id,
    $comment,
    false
);

题目中的 confirm() 和 update() 是较老版本的调用方式。具体代码需要根据当前项目中 catalog/model/checkout/order.php 实际提供的方法调整。

一个更可靠的 callback 示例

下面的示例沿用旧版 OpenCart 的 confirm() 和 update() 接口。签名验证部分仍需按照支付平台文档实现:

public function callback()
{
    $this->load->model('checkout/order');

    $order_id = isset($this->request->post['merchant_reference'])
        ? (int)$this->request->post['merchant_reference']
        : 0;

    if (!$order_id) {
        $this->response->addHeader(
            $this->request->server['SERVER_PROTOCOL'] . ' 400 Bad Request'
        );
        $this->response->setOutput('Missing order reference');
        return;
    }

    $order_info = $this->model_checkout_order->getOrder($order_id);

    if (!$order_info) {
        $this->response->addHeader(
            $this->request->server['SERVER_PROTOCOL'] . ' 404 Not Found'
        );
        $this->response->setOutput('Order not found');
        return;
    }

    if (!$this->verifyHyperionCallback($this->request->post, $order_info)) {
        $this->response->addHeader(
            $this->request->server['SERVER_PROTOCOL'] . ' 403 Forbidden'
        );
        $this->response->setOutput('Invalid signature');
        return;
    }

    $payment_status = isset($this->request->post['flag_msg'])
        ? $this->request->post['flag_msg']
        : '';

    $message = 'Payment Status = ' . $payment_status . "\n";

    if (isset($this->request->post['Response'])) {
        $message .= 'Response = ' . $this->request->post['Response'] . "\n";
    }

    if (isset($this->request->post['Result'])) {
        $message .= 'Result = ' . $this->request->post['Result'] . "\n";
    }

    if ($payment_status === 'Status_Success') {
        $status_id = (int)$this->config->get('hyper_order_status_id');

        $this->model_checkout_order->update(
            $order_id,
            $status_id,
            $message,
            false
        );

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

    if ($payment_status === 'Status_Fail') {
        $status_id = (int)$this->config->get('hyper_failed_status_id');

        if (isset($this->request->post['message'])) {
            $message .= 'Reason = ' . $this->request->post['message'] . "\n";
        }

        $this->model_checkout_order->update(
            $order_id,
            $status_id,
            $message,
            false
        );

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

    $this->response->addHeader(
        $this->request->server['SERVER_PROTOCOL'] . ' 400 Bad Request'
    );
    $this->response->setOutput('Unknown payment status');
}

签名验证方法可以采用以下结构:

private function verifyHyperionCallback(array $post, array $order_info)
{
    // 按支付平台文档:
    // 1. 读取签名字段;
    // 2. 使用共享密钥重新计算签名;
    // 3. 使用 hash_equals() 比较;
    // 4. 核对金额、币种、商户号和订单号;
    // 5. 必要时主动查询支付平台交易状态。

    return false;
}

在没有明确签名规则的情况下,不能为了让流程通过而让这个方法始终返回 true。

失败状态必须使用数字 ID

原代码中的这一行有误:

$this->model_checkout_order->update($order_id, 'Failed', $message, false);

订单模型需要的是订单状态 ID,不是状态名称。应该在模块设置中保存失败状态的 ID,并以整数形式传入:

$failed_status_id = (int)$this->config->get('hyper_failed_status_id');

$this->model_checkout_order->update(
    $order_id,
    $failed_status_id,
    $message,
    false
);

成功状态的配置也需要确认存在,且值不能为空:

$success_status_id = (int)$this->config->get('hyper_order_status_id');

分开处理浏览器返回页面和服务器通知

支付平台通常会发起两类请求:

  • callback 或 notify:由支付平台服务器通知 OpenCart,负责更新订单。
  • return_url:由用户浏览器返回商店,负责显示支付结果。

服务器通知通常不需要渲染完整的主题模板。完成请求验证和订单更新后,返回支付平台要求的响应即可,例如 OK。

浏览器返回可以由单独的方法处理:

public function returnPage()
{
    $this->redirect($this->url->link('checkout/success', '', 'SSL'));
}

如果需要显示自定义的成功或失败模板,也应该读取数据库中的真实订单状态,不能只相信浏览器提交的 flag_msg。

还需要检查的配置

修复代码后,逐项确认以下内容:

  1. 支付平台配置的回调 URL 是完整的公网 HTTPS 地址。
  2. 路由名称、控制器文件名和类名保持一致。
  3. 回调 URL 没有被 .htaccess、防火墙、维护模式或安全插件拦截。
  4. 支付平台确实使用 POST 发送请求,字段名与代码完全一致。
  5. hyper_order_status_id 和 hyper_failed_status_id 保存的是有效数字 ID。
  6. 跳转支付前已经对订单调用 confirm()。
  7. 回调通过平台返回的商户参考号定位订单,不依赖 Session。
  8. 只有在签名、金额、币种和商户号均通过核验后,才能把订单更新为成功状态。
  9. 重复通知需要进行幂等处理,避免同一订单被多次更新或重复发送通知。
  10. 检查 OpenCart 日志和 PHP 错误日志。调试时不能只根据空白页判断错误。

这里要解决的不只是模板问题。路由名称必须保持一致,订单识别方式也要从浏览器 Session 改为经过签名验证的支付平台订单引用。完成这些修改后,返回页面才能正常访问,订单会在跳转支付前进入后台,并在支付结果到达后更新为正确状态。

备注:内容仅供参考。