PAYATHON 2026

CodeIgniter 集成 VoguePay 支付后无法返回商户网站

支付老李

结论

支付本身已经成功,问题出在回调地址和通知处理逻辑上:

  1. View 必须向 VoguePay 提交公网可访问的 notify_url、success_url 和 fail_url。
  2. notify() 不能写死 v_transaction_id=11111,而要读取 VoguePay 实际返回的 transaction_id。
  3. 查询交易详情的 URL 不能被换行拆开,尤其不能在 ? 后换行。
  4. 通知接口和浏览器返回页面要分开处理。通知接口负责验签、查单和更新订单,成功页面只展示结果。
  5. 如果 CodeIgniter 开启了 CSRF,第三方服务器向通知接口发起 POST 请求时可能被拦截。需要为该路由配置例外,或按项目现有的 CSRF 机制处理。
  6. 当前代码在确认交易通过后,没有明确更新订单、返回响应或跳转。即使通知已经到达,用户也看不到完整的支付成功流程。

推荐的支付表单配置

下面的示例使用 VoguePay 旧版表单参数名。不同账户或接口版本支持的字段可能不同,请以当前 VoguePay 商户后台和接口文档为准。

<form method="post" action="https://voguepay.com/pay/">
    <input type="hidden" name="v_merchant_id" value="<?= html_escape($merchant_id) ?>">
    <input type="hidden" name="merchant_ref" value="<?= html_escape($order_reference)) ?>">
    <input type="hidden" name="memo" value="Order payment">

    <input type="hidden" name="total" value="<?= html_escape($amount) ?>">

    <input
        type="hidden"
        name="notify_url"
        value="<?= site_url('vgniter/notify') ?>"
    >

    <input
        type="hidden"
        name="success_url"
        value="<?= site_url('vgniter/success') ?>"
    >

    <input
        type="hidden"
        name="fail_url"
        value="<?= site_url('vgniter/failed') ?>"
    >

    <button type="submit">Pay Now</button>
</form>

这三个地址各有用途:

  • notify_url:接收 VoguePay 服务器发送的交易通知。
  • success_url:付款完成后,用户浏览器返回的页面。
  • fail_url:付款失败或取消后,用户浏览器返回的页面。

不要把 notify_url 当作普通的支付成功页面。服务器通知可能早于或晚于用户跳转,也可能重复发送。

修改 Controller

可以按下面的方式处理:

<?php
defined('BASEPATH') OR exit('No direct script access allowed');

class Vgniter extends CI_Controller
{
    public function __construct()
    {
        parent::__construct();

        $this->load->library('Vgniter_lib');
        $this->load->helper(array('url', 'security'));
    }

    public function index()
    {
        $this->template->loadData(
            'activeLink',
            array('vgniter' => array('general' => 1))
        );

        if (!$this->settings->info->payment_enabled) {
            $this->template->error(lang('error_60'));
            return;
        }

        $this->template->loadContent('vgniter/index.php', array());
    }

    public function notify()
    {
        $transactionId = $this->input->post('transaction_id', true);

        /*
         * 某些接口流程可能通过查询参数返回 transaction_id。
         * 只有在当前 VoguePay 接口确实如此时才保留该回退逻辑。
         */
        if (empty($transactionId)) {
            $transactionId = $this->input->get('transaction_id', true);
        }

        if (empty($transactionId)) {
            return $this->respond(400, 'Missing transaction_id');
        }

        $query = http_build_query(array(
            'v_transaction_id' => $transactionId,
            'type'             => 'json',
            'demo'             => 'true'
        ));

        $url = 'https://voguepay.com/?' . $query;
        $json = @file_get_contents($url);

        if ($json === false) {
            log_message(
                'error',
                'Unable to verify VoguePay transaction: ' . $transactionId
            );

            return $this->respond(502, 'Unable to verify transaction');
        }

        $transaction = json_decode($json, true);

        if (!is_array($transaction) || json_last_error() !== JSON_ERROR_NONE) {
            log_message(
                'error',
                'Invalid VoguePay verification response: ' . $transactionId
            );

            return $this->respond(502, 'Invalid verification response');
        }

        if (
            empty($transaction['transaction_id']) ||
            (string) $transaction['transaction_id'] !== (string) $transactionId
        ) {
            return $this->respond(400, 'Transaction ID mismatch');
        }

        if (
            empty($transaction['merchant_id']) ||
            (string) $transaction['merchant_id'] !== (string) $this->config->item('voguepay_merchant_id')
        ) {
            return $this->respond(400, 'Merchant ID mismatch');
        }

        if (
            empty($transaction['status']) ||
            $transaction['status'] !== 'Approved'
        ) {
            return $this->respond(400, 'Transaction not approved');
        }

        $merchantReference = $transaction['merchant_ref'] ?? '';

        if ($merchantReference === '') {
            return $this->respond(400, 'Missing merchant reference');
        }

        /*
         * 必须根据 merchant_ref 查询本地订单。
         *
         * 示例:
         * $order = $this->order_model->findByReference($merchantReference);
         */
        $order = $this->order_model->findByReference($merchantReference);

        if (!$order) {
            return $this->respond(404, 'Order not found');
        }

        /*
         * 金额不要直接用浮点数比较。
         * 如果数据库保存的是最小货币单位,应将双方统一转换后再比较。
         */
        $expectedAmount = number_format((float) $order->amount, 2, '.', '');
        $paidAmount = number_format(
            (float) ($transaction['total'] ?? 0),
            2,
            '.',
            ''
        );

        if ($paidAmount !== $expectedAmount) {
            log_message(
                'error',
                'VoguePay amount mismatch for order: ' . $merchantReference
            );

            return $this->respond(400, 'Amount mismatch');
        }

        /*
         * 通知可能被重复发送,更新必须具有幂等性。
         */
        if ($order->payment_status !== 'paid') {
            $this->order_model->markAsPaid(
                $order->id,
                $transactionId,
                $transaction
            );
        }

        return $this->respond(200, 'OK');
    }

    public function success()
    {
        /*
         * 不要仅凭浏览器跳转就把订单标记为已支付。
         * 应从数据库读取 notify() 已验证并保存的支付状态。
         */
        $reference = $this->input->get('merchant_ref', true);

        $this->template->loadContent(
            'vgniter/success.php',
            array('reference' => $reference)
        );
    }

    public function failed()
    {
        $this->template->loadContent('vgniter/failed.php', array());
    }

    private function respond($statusCode, $message)
    {
        return $this->output
            ->set_status_header($statusCode)
            ->set_content_type('text/plain', 'utf-8')
            ->set_output($message);
    }
}

示例里的 order_model、findByReference() 和 markAsPaid() 只是占位写法,需要替换为项目实际使用的订单模型和方法。

原代码中的具体问题

1. 交易编号被写死

原代码始终查询:

v_transaction_id=11111

这个编号与当前支付交易无关,应该改为读取回调中的值:

$transactionId = $this->input->post('transaction_id', true);

再安全地构造查询参数:

$url = 'https://voguepay.com/?' . http_build_query(array(
    'v_transaction_id' => $transactionId,
    'type' => 'json',
    'demo' => 'true'
));

2. 验证地址被错误换行

原字符串类似这样:

'https://voguepay.com/?
v_transaction_id=11111&type=json&demo=true'

换行会成为 URL 的一部分,可能导致请求失败。URL 必须连续构造,不能紧接着 ? 换行。

3. View 没有提供回调地址

支付表单没有提交 notify_url 时,VoguePay 不知道该把通知发送到哪里。如果 success_url 也没有正确设置,支付完成后,用户浏览器就无法返回商户网站。

建议使用下面的方式生成地址:

site_url('vgniter/notify')

不要手工拼接相对路径。

4. 通知接口不应使用 redirect()

调用 notify_url 的是 VoguePay 服务器,不是用户浏览器。因此,下面的逻辑通常起不到预期作用:

redirect(base_url('vgniter/failed'));

VoguePay 服务器只会收到 HTTP 重定向响应,用户所在的页面不会跟着跳转。通知接口应该返回明确的 HTTP 状态码和简短文本:

return $this->output
    ->set_status_header(400)
    ->set_output('Transaction not approved');

用户浏览器的跳转应由 success_url 和 fail_url 处理。

5. 缺少真实的订单校验

仅检查下面这个条件并不够:

$transaction['status'] === 'Approved'

还需要至少验证以下内容:

  • transaction_id 是否与回调值一致;
  • merchant_id 是否属于当前商户;
  • merchant_ref 是否能对应到本地订单;
  • 实付金额是否与订单金额一致;
  • 该笔交易是否已经处理过;
  • 生产环境是否误用了 demo=true。

如果 VoguePay 当前接口提供签名或校验散列,还需要按照官方规则验签。

CodeIgniter 路由和 CSRF

先确保这些路由可以被外部请求访问。例如:

$route['vgniter/notify'] = 'vgniter/notify';
$route['vgniter/success'] = 'vgniter/success';
$route['vgniter/failed'] = 'vgniter/failed';

如果启用了 CodeIgniter 3 CSRF,VoguePay 无法携带站点生成的 CSRF token,通知请求可能在进入 Controller 前就被拦截并返回 403。确认框架版本和安全要求后,可以为通知路由配置例外:

$config['csrf_exclude_uris'] = array(
    'vgniter/notify'
);

通知接口排除 CSRF 检查后,安全性必须由服务端查单、商户号校验、金额校验和接口签名来保证,不能直接信任 POST 内容。

部署时还要检查

  • notify_url 必须是公网可访问的完整 HTTPS 地址,不能使用 localhost 或内网地址。
  • 通知路由不能要求用户登录,也不能依赖浏览器 Session。
  • 查看 Web 服务器访问日志和 CodeIgniter 日志,确认通知是否已经到达。
  • 确认防火墙、WAF 和反向代理没有拦截 VoguePay 请求。
  • 生产环境应移除 demo=true,具体参数仍以当前 VoguePay 接口要求为准。
  • file_get_contents() 依赖 PHP 的 allow_url_fopen。如果服务器禁用了该配置,需要改用 cURL,并设置连接超时、响应超时和 TLS 校验。
  • 支付通知可能延迟到达,也可能重复发送。订单更新必须具有幂等性,最好保留原始通知和查单结果,以便审计。
  • 浏览器到达 success_url 并不表示付款已经通过。最终支付状态只能以服务器端验证结果为准。

备注:内容仅供参考。