PAYATHON 2026

如何在 OpenCart 支付扩展中更新订单状态

支付阿杰

结论

订单出现在 Missing Orders,通常是因为订单已经写入数据库,但 order_status_id 仍为 0。支付扩展需要完成两次状态处理:

  1. 用户确认付款、跳转到网关前,将订单设为“等待付款”。
  2. 支付网关通过服务器回调通知结果后,验证回调真实性,再将订单更新为“已完成”或“支付失败”。

不要根据用户浏览器返回的 checkout/success 直接认定付款成功。用户可能关闭页面、伪造请求,网关也可能重复发送通知。最终支付状态应以经过签名验证的服务器回调为准。

先在跳转支付前初始化订单状态

当前代码生成了 notify_url,但没有看到订单历史初始化逻辑。可以在用户点击确认付款时增加一个 confirm() 方法:

public function confirm() {
    $json = array();

    if (!isset($this->session->data['order_id'])) {
        $json['error'] = 'Order ID is missing.';
    } else {
        $this->load->model('checkout/order');

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

        if (!$order_info) {
            $json['error'] = 'Order not found.';
        } elseif (!(int)$order_info['order_status_id']) {
            $pending_status_id = (int)$this->config->get('sc_pay_pending_status_id');

            $this->model_checkout_order->addOrderHistory(
                $order_id,
                $pending_status_id,
                'Customer redirected to payment gateway.',
                false
            );
        }
    }

    $this->response->addHeader('Content-Type: application/json');
    $this->response->setOutput(json_encode($json));
}

这里的 sc_pay_pending_status_id 应由扩展后台配置,并对应一个“等待付款”状态。不要把状态 ID 写死,因为不同商店的状态 ID 可能不同。

前端应先调用这个接口,成功后再提交到支付网关。例如:

$('#button-confirm').on('click', function () {
    $.ajax({
        url: 'index.php?route=extension/payment/sc_pay/confirm',
        dataType: 'json',
        success: function (json) {
            if (json.error) {
                alert(json.error);
                return;
            }

            $('#sc-pay-form').submit();
        }
    });
});

具体表单 ID 和提交方式需要按照扩展的 Twig 模板调整。

实现服务器 callback

控制器中可以增加以下回调入口:

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

    $raw_body = file_get_contents('php://input');
    $payload = json_decode($raw_body, true);

    if (!is_array($payload)) {
        $payload = $this->request->post;
    }

    /*
     * 以下字段名只是示例,必须替换成支付网关实际返回的字段:
     *
     * order_id
     * status
     * amount
     * currency
     * transaction_id
     * signature
     */

    $order_id = isset($payload['order_id']) ? (int)$payload['order_id'] : 0;

    if (!$order_id) {
        $this->response->addHeader('HTTP/1.1 400 Bad Request');
        $this->response->setOutput('Missing order ID');
        return;
    }

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

    if (!$order_info) {
        $this->response->addHeader('HTTP/1.1 404 Not Found');
        $this->response->setOutput('Order not found');
        return;
    }

    if (!$this->verifyCallback($payload, $raw_body)) {
        $this->log->write(
            'SC Pay: callback verification failed for order ' . $order_id
        );

        $this->response->addHeader('HTTP/1.1 400 Bad Request');
        $this->response->setOutput('Invalid callback');
        return;
    }

    if (!$this->paymentMatchesOrder($payload, $order_info)) {
        $this->log->write(
            'SC Pay: amount or currency mismatch for order ' . $order_id
        );

        $this->response->addHeader('HTTP/1.1 400 Bad Request');
        $this->response->setOutput('Payment data mismatch');
        return;
    }

    $status = isset($payload['status'])
        ? strtolower((string)$payload['status'])
        : '';

    if ($status === 'successful' || $status === 'completed') {
        $order_status_id = (int)$this->config->get(
            'sc_pay_completed_status_id'
        );

        $comment = 'Payment confirmed by SC Pay';

        if (!empty($payload['transaction_id'])) {
            $comment .= '. Transaction ID: ' . $payload['transaction_id'];
        }

        if ((int)$order_info['order_status_id'] !== $order_status_id) {
            $this->model_checkout_order->addOrderHistory(
                $order_id,
                $order_status_id,
                $comment,
                true
            );
        }
    } elseif ($status === 'failed' || $status === 'cancelled') {
        $order_status_id = (int)$this->config->get(
            'sc_pay_failed_status_id'
        );

        if ((int)$order_info['order_status_id'] !== $order_status_id) {
            $this->model_checkout_order->addOrderHistory(
                $order_id,
                $order_status_id,
                'Payment failed or was cancelled.',
                false
            );
        }
    } else {
        $this->response->addHeader('HTTP/1.1 400 Bad Request');
        $this->response->setOutput('Unknown payment status');
        return;
    }

    $this->response->addHeader('HTTP/1.1 200 OK');
    $this->response->setOutput('OK');
}

验证回调签名

verifyCallback() 必须严格按照支付网关文档实现。假设网关使用请求原文和 HMAC-SHA256 签名,可以参考:

private function verifyCallback(array $payload, $raw_body) {
    $received_signature = isset($this->request->server['HTTP_X_SC_SIGNATURE'])
        ? $this->request->server['HTTP_X_SC_SIGNATURE']
        : '';

    if ($received_signature === '') {
        return false;
    }

    $secret = (string)$this->config->get('sc_pay_secret');
    $expected_signature = hash_hmac('sha256', $raw_body, $secret);

    return hash_equals($expected_signature, $received_signature);
}

这只是 HMAC 网关的示例。如果网关要求拼接特定字段、使用公钥验签,或者要求回调后再次查询交易状态,就必须按其协议实现,不能直接套用上述算法。

不要只检查回调中的 status=completed。至少还应验证:

  • 签名或消息认证码;
  • 商户账号是否属于当前商店;
  • order_id 是否存在;
  • 支付金额是否与订单金额一致;
  • 币种是否一致;
  • 交易 ID 是否已经处理;
  • 网关交易查询结果是否确实成功。

金额比较应避免直接比较浮点数。若网关以最小货币单位返回金额,可以这样检查:

private function paymentMatchesOrder(array $payload, array $order_info) {
    if (!isset($payload['amount'], $payload['currency'])) {
        return false;
    }

    $expected_amount = (int)round((float)$order_info['total'] * 100);
    $received_amount = (int)$payload['amount'];

    if ($received_amount !== $expected_amount) {
        return false;
    }

    return strtoupper((string)$payload['currency'])
        === strtoupper((string)$order_info['currency_code']);
}

只有在网关明确以分、便士等最小单位返回金额时,这种写法才适用。零小数或三位小数币种需要根据网关的金额规则单独处理。

callback 和浏览器返回地址要分开

notify_url 是网关服务器调用的接口,它只能返回 HTTP 响应,不能可靠地把顾客重定向到结账页。浏览器跳转应由网关的成功地址和失败地址负责:

$data['return'] = $this->url->link(
    'extension/payment/sc_pay/returnSuccess',
    '',
    true
);

$data['cancel_return'] = $this->url->link(
    'extension/payment/sc_pay/returnCancel',
    '',
    true
);

$data['notify_url'] = $this->url->link(
    'extension/payment/sc_pay/callback',
    '',
    true
);

成功返回方法可以检查数据库中的订单状态:

public function returnSuccess() {
    if (!isset($this->session->data['order_id'])) {
        $this->response->redirect(
            $this->url->link('checkout/checkout', '', true)
        );
        return;
    }

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

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

    $completed_status_id = (int)$this->config->get(
        'sc_pay_completed_status_id'
    );

    if (
        $order_info &&
        (int)$order_info['order_status_id'] === $completed_status_id
    ) {
        $this->response->redirect(
            $this->url->link('checkout/success', '', true)
        );
        return;
    }

    /*
     * callback 可能比浏览器返回稍晚。
     * 此处可以显示“付款确认中”页面,或向网关主动查询交易结果。
     */
    $this->response->redirect(
        $this->url->link('checkout/checkout', '', true)
    );
}

取消或失败返回方法可以记录失败状态,但要注意:用户返回失败页并不能证明交易一定失败。若网关可能出现“付款已完成,但浏览器跳转失败”的情况,应先查询网关交易状态,再决定是否写入失败状态。

public function returnCancel() {
    if (isset($this->session->data['order_id'])) {
        $this->load->model('checkout/order');

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

        if ($order_info) {
            $failed_status_id = (int)$this->config->get(
                'sc_pay_failed_status_id'
            );

            $this->model_checkout_order->addOrderHistory(
                $order_id,
                $failed_status_id,
                'Customer returned from the payment cancellation page.',
                false
            );
        }
    }

    $this->response->redirect(
        $this->url->link('checkout/checkout', '', true)
    );
}

更稳妥的做法是把这种情况标记为“已取消”或“待确认”,而不是直接标记为最终失败。

需要修改当前跳转参数

当前代码把:

$newbutton = $newbutton . "&redirect_url=" . $this->url->link('checkout/success');

直接指向了 checkout/success。这会让顾客看到成功页面,但不会证明付款成功,也不会自动更新订单状态。应改成扩展自己的返回入口,并正确编码 URL:

$return_url = $this->url->link(
    'extension/payment/sc_pay/returnSuccess',
    '',
    true
);

$newbutton .= '&redirect_url=' . rawurlencode($return_url);

如果支付网关通过表单字段接收 URL,最好直接将返回地址作为模板数据提交,不要手动拼接查询字符串。

版本兼容和安全注意事项

示例按 OpenCart 2.x/3.x 常见的控制器结构编写:

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

不同 OpenCart 主版本、主题或扩展框架可能使用不同的命名空间、路由和订单 API,实际代码需要按当前版本调整。

另外还应注意:

  • 不要从 callback 的 session 中读取订单号。服务器回调通常没有顾客的 session。
  • 订单号应通过网关的不可篡改字段传递,例如签名覆盖的 custom 或 merchant_reference。
  • 不要把订单号和金额直接拼入可被用户修改的支付链接而不签名。
  • callback 必须支持重复通知,避免重复发送邮件、重复扣库存或重复写入相同状态。
  • 保存网关的交易 ID,并对其建立唯一性检查。
  • 记录验签失败和金额不匹配,但不要把密钥、银行卡数据或完整敏感请求写入日志。
  • 使用 HTTPS 的绝对 callback URL,并确保该 URL 不需要顾客登录。
  • callback 成功处理后返回网关要求的确认文本和 HTTP 状态码,否则网关可能持续重试。

核心原则是:跳转前把订单从 order_status_id = 0 改为等待付款;验签后的 callback 决定最终订单状态;浏览器返回地址只负责展示结果和导航。

备注:内容仅供参考。