如何在 OpenCart 支付扩展中更新订单状态
结论
订单出现在 Missing Orders,通常是因为订单已经写入数据库,但 order_status_id 仍为 0。支付扩展需要完成两次状态处理:
- 用户确认付款、跳转到网关前,将订单设为“等待付款”。
- 支付网关通过服务器回调通知结果后,验证回调真实性,再将订单更新为“已完成”或“支付失败”。
不要根据用户浏览器返回的 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 决定最终订单状态;浏览器返回地址只负责展示结果和导航。
备注:内容仅供参考。
