PAYATHON 2026

如何在 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 主版本使用的支付扩展目录、命名空间和路由格式并不相同,具体实现应遵循目标版本中现有支付模块的结构。

数据库写入与网关请求必须按以下顺序执行:

  1. OpenCart 创建订单。
  2. 服务端生成 transaction ID。
  3. 保存本地交易记录,并将初始状态设为 pending。
  4. 将 transaction ID 提交给网关。
  5. 收到网关回调后验证支付结果。
  6. 验证成功后更新订单状态。

不要先跳转到网关,再尝试保存交易号。如果用户关闭页面或网络中断,本地可能找不到对应的交易记录。

封装为支付模型

在实际扩展中,数据库操作更适合放在支付模型内,不应全部写在控制器中:

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 的字段名称、签名规则和验证接口可能发生调整。最终请求参数应以当前官方集成文档为准。

备注:内容仅供参考。