如何在 Opencart 结账时自动生成 transaction ID?
结论
商城应在跳转至支付网关前生成全局唯一的 merchant transaction ID,将其与 OpenCart 的 order_id 关联并写入数据库,再把同一个 ID 作为 tran_id 提交给 SSLCOMMERZ。
收到支付回调后,不能仅凭这个 ID 判断支付成功。系统还需验证网关签名或调用验证接口,并核对订单金额、币种和支付状态。全部确认无误后,才能更新 OpenCart 订单状态。
不要直接使用 order_id 作为 transaction ID。order_id 通常连续且容易预测,同一订单也可能发起多次支付。
推荐的数据结构
建议为支付模块创建独立的交易表。这样无须修改 OpenCart 核心订单表,也能记录同一订单的多次支付尝试。
CREATE TABLE `oc_sslcommerz_transaction` (
`sslcommerz_transaction_id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` INT UNSIGNED NOT NULL,
`transaction_id` VARCHAR(64) NOT NULL,
`gateway_transaction_id` VARCHAR(128) DEFAULT NULL,
`amount` DECIMAL(15,4) NOT NULL,
`currency_code` VARCHAR(3) NOT NULL,
`status` VARCHAR(32) NOT NULL DEFAULT 'pending',
`validation_id` VARCHAR(128) DEFAULT NULL,
`response_data` MEDIUMTEXT DEFAULT NULL,
`date_added` DATETIME NOT NULL,
`date_modified` DATETIME NOT NULL,
PRIMARY KEY (`sslcommerz_transaction_id`),
UNIQUE KEY `uq_transaction_id` (`transaction_id`),
KEY `idx_order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
安装扩展时,应使用 OpenCart 的数据库前缀,不要写死 oc_:
DB_PREFIX . 'sslcommerz_transaction'
如果业务能够保证一个订单永远只有一笔交易,也可以直接在订单表中增加字段。不过,这种设计不便于处理支付失败后的重试、重复回调和重新发起支付。
生成 transaction ID
transaction ID 必须由服务器生成,不能接受浏览器传入的任意值。可以把订单号和加密随机数组合起来:
private function generateTransactionId(int $orderId): string {
return sprintf(
'OC%s-%s',
$orderId,
strtoupper(bin2hex(random_bytes(8)))
);
}
生成结果类似:
OC1024-5DB846A069A4C937
random_bytes() 适用于现代 PHP 环境。如果当前 OpenCart 使用的 PHP 版本不支持该函数,需要换用兼容的安全随机数方案,不能只用 rand() 或当前时间戳。
同时,应检查 SSLCOMMERZ 当前接口是否限制 tran_id 的长度和可用字符,并据此调整格式。
在发起支付前保存记录
OpenCart 创建订单后,通常可以从当前会话中取得订单 ID:
$orderId = (int)$this->session->data['order_id'];
$orderInfo = $this->model_checkout_order->getOrder($orderId);
取得订单后,生成交易号并立即写入数据库:
$transactionId = $this->generateTransactionId($orderId);
$this->db->query("
INSERT INTO `" . DB_PREFIX . "sslcommerz_transaction`
SET
`order_id` = '" . (int)$orderId . "',
`transaction_id` = '" . $this->db->escape($transactionId) . "',
`amount` = '" . (float)$orderInfo['total'] . "',
`currency_code` = '" . $this->db->escape($orderInfo['currency_code']) . "',
`status` = 'pending',
`date_added` = NOW(),
`date_modified` = NOW()
");
随后,把同一个 transaction ID 放入网关请求:
$paymentData = [
'tran_id' => $transactionId,
'total_amount' => $orderInfo['total'],
'currency' => $orderInfo['currency_code'],
'success_url' => $this->url->link('extension/payment/sslcommerz/callback', '', true),
'fail_url' => $this->url->link('extension/payment/sslcommerz/fail', '', true),
'cancel_url' => $this->url->link('extension/payment/sslcommerz/cancel', '', true),
];
这些路由仅作示例。不同 OpenCart 主版本使用的支付扩展目录、命名空间和路由格式并不相同,具体实现应遵循目标版本中现有支付模块的结构。
数据库写入与网关请求必须按以下顺序执行:
- OpenCart 创建订单。
- 服务端生成 transaction ID。
- 保存本地交易记录,并将初始状态设为
pending。 - 将 transaction ID 提交给网关。
- 收到网关回调后验证支付结果。
- 验证成功后更新订单状态。
不要先跳转到网关,再尝试保存交易号。如果用户关闭页面或网络中断,本地可能找不到对应的交易记录。
封装为支付模型
在实际扩展中,数据库操作更适合放在支付模型内,不应全部写在控制器中:
public function addTransaction(
int $orderId,
string $transactionId,
float $amount,
string $currencyCode
): void {
$this->db->query("
INSERT INTO `" . DB_PREFIX . "sslcommerz_transaction`
SET
`order_id` = '" . (int)$orderId . "',
`transaction_id` = '" . $this->db->escape($transactionId) . "',
`amount` = '" . (float)$amount . "',
`currency_code` = '" . $this->db->escape($currencyCode) . "',
`status` = 'pending',
`date_added` = NOW(),
`date_modified` = NOW()
");
}
public function getTransaction(string $transactionId): array {
$query = $this->db->query("
SELECT *
FROM `" . DB_PREFIX . "sslcommerz_transaction`
WHERE `transaction_id` = '" . $this->db->escape($transactionId) . "'
LIMIT 1
");
return $query->row;
}
字段转义不能省略。即使 transaction ID 最初由服务器生成,回调中收到的值仍然属于外部输入。
正确处理支付回调
回调处理程序应先读取 tran_id,再查找对应的本地交易记录:
$transactionId = isset($this->request->post['tran_id'])
? (string)$this->request->post['tran_id']
: '';
$transaction = $this->model_extension_payment_sslcommerz
->getTransaction($transactionId);
if (!$transaction) {
// 返回失败响应并记录日志
return;
}
找到记录并不代表付款已经成功。之后至少要检查:
- 回调签名或哈希是否有效;
- 是否已通过 SSLCOMMERZ 的服务端验证接口确认;
tran_id是否与本地记录一致;- 支付金额是否与本地保存的金额一致;
- 币种是否一致;
- 网关状态是否为明确的成功状态;
- 该交易是否已经处理。
验证通过后,再保存网关返回的交易信息并更新本地状态:
$this->db->query("
UPDATE `" . DB_PREFIX . "sslcommerz_transaction`
SET
`gateway_transaction_id` = '" .
$this->db->escape($gatewayTransactionId) . "',
`validation_id` = '" .
$this->db->escape($validationId) . "',
`status` = 'paid',
`response_data` = '" .
$this->db->escape(json_encode($responseData, JSON_UNESCAPED_UNICODE)) . "',
`date_modified` = NOW()
WHERE `transaction_id` = '" .
$this->db->escape($transactionId) . "'
AND `status` = 'pending'
");
最后,通过 OpenCart 的订单模型添加订单历史,不要直接修改订单表:
$this->model_checkout_order->addOrderHistory(
(int)$transaction['order_id'],
$paidOrderStatusId,
'SSLCOMMERZ payment verified. Transaction ID: ' . $transactionId,
true
);
addOrderHistory() 的具体参数和模型加载方式会随 OpenCart 版本变化,应以目标版本的方法签名为准。
防止重复回调
支付平台可能多次发送成功通知。如果每次回调都确认订单、扣减库存或发送邮件,就会造成重复处理。
更新交易时,可以限定记录的原始状态:
UPDATE oc_sslcommerz_transaction
SET status = 'paid', date_modified = NOW()
WHERE transaction_id = '...'
AND status = 'pending';
执行后检查受影响的记录数。只有第一次成功将状态从 pending 改为 paid 时,才继续更新订单状态及执行其他后续操作。
交易状态还应明确区分为:
pending
paid
failed
cancelled
refunded
浏览器跳转到 success_url 不能作为最终的支付依据。该访问请求可以被伪造,用户也可能在付款成功后关闭浏览器。最终状态应以经过服务端验证的网关通知或主动查询结果为准。
金额比较要避免浮点误差
支付金额不适合直接使用普通浮点数进行严格相等比较。可以将金额换算成最小货币单位后再比较:
$expectedAmount = (int)round(((float)$transaction['amount']) * 100);
$receivedAmount = (int)round(((float)$gatewayAmount) * 100);
if ($expectedAmount !== $receivedAmount) {
// 金额不一致,拒绝确认订单
return;
}
这段代码以币种使用两位小数为前提。如果系统需要支持小数位数不同的币种,应按照相应币种规则统一金额精度。
实现时还要注意
- 不要修改 OpenCart 核心文件。尽量创建独立的支付扩展,以免升级时被覆盖。
- transaction ID 必须设置唯一索引。数据库是防止重复值的最后一道保障。
- 支付密钥只能保存在服务端配置中,不能输出到页面或 JavaScript。
- 日志可以记录 transaction ID 和验证结果,但不能记录完整银行卡信息、密钥或其他敏感数据。
- 支付失败后再次尝试时,应为同一个
order_id创建新的 transaction ID。 - 保存网关原始响应有助于排查问题,但需要过滤敏感字段,并设置合理的保留期限。
- 开发阶段应使用网关沙箱环境,分别测试成功、失败、取消、金额不一致、重复通知和回调乱序等情况。
- SSLCOMMERZ 的字段名称、签名规则和验证接口可能发生调整。最终请求参数应以当前官方集成文档为准。
备注:内容仅供参考。
