PAYATHON 2026

如何在 OpenCart 中集成 CitrusPay Payment Gateway

支付阿杰

结论

CitrusPay 不能直接嵌入 OpenCart 的现有页面调用,需要封装成独立的支付扩展。扩展一般包括后台配置、前台支付逻辑、支付跳转页面和回调处理,并通过 OpenCart 的订单状态机制确认付款结果。

不要修改 OpenCart 核心文件。可以参考当前版本已有的跳转型支付网关扩展,例如 PayPal Standard,再把请求参数、签名算法和回调验证替换成 CitrusPay PHP integration kit 提供的实现。

OpenCart 各版本的扩展目录和接口差异较大。以下示例采用 OpenCart 3.x 的传统扩展结构。如果使用 OpenCart 1.5、2.x 或 4.x,需要参照对应版本已有的支付扩展,调整目录、命名空间和事件接口。

需要创建的文件

一个基本的 CitrusPay 支付扩展通常需要这些文件:

admin/
├── controller/extension/payment/citruspay.php
├── language/en-gb/extension/payment/citruspay.php
├── model/extension/payment/citruspay.php
└── view/template/extension/payment/citruspay.twig

catalog/
├── controller/extension/payment/citruspay.php
├── language/en-gb/extension/payment/citruspay.php
├── model/extension/payment/citruspay.php
└── view/theme/default/template/extension/payment/citruspay.twig

各文件的用途如下:

  • admin/controller:保存商户号、密钥、测试模式和订单状态等配置。
  • admin/model:处理扩展安装或卸载时所需的数据表或事件。简单扩展可能不需要额外的数据表。
  • admin/view:后台配置页面。
  • catalog/model:判断结账时是否显示 CitrusPay。
  • catalog/controller:准备支付请求、生成签名并接收回调。
  • catalog/view:生成提交到支付网关的表单。
  • language:保存后台和前台显示的文本。

如果店铺使用自定义主题,需要把前台模板放到对应的主题目录中,或者确认该主题可以回退到 default 模板。

后台注册支付方式

后台控制器用于显示和保存配置。下面是一个简化示例,其中的配置字段只是扩展内部命名,不代表 CitrusPay integration kit 的真实参数名:

<?php
class ControllerExtensionPaymentCitruspay extends Controller
{
    private $error = [];

    public function index()
    {
        $this->load->language('extension/payment/citruspay');
        $this->document->setTitle($this->language->get('heading_title'));

        $this->load->model('setting/setting');

        if (
            $this->request->server['REQUEST_METHOD'] === 'POST'
            && $this->validate()
        ) {
            $this->model_setting_setting->editSetting(
                'payment_citruspay',
                $this->request->post
            );

            $this->session->data['success'] =
                $this->language->get('text_success');

            $this->response->redirect(
                $this->url->link(
                    'marketplace/extension',
                    'user_token=' . $this->session->data['user_token']
                    . '&type=payment',
                    true
                )
            );
        }

        // 在这里加载配置值、面包屑、订单状态和模板变量。
        $this->response->setOutput(
            $this->load->view('extension/payment/citruspay', $data)
        );
    }

    protected function validate()
    {
        if (!$this->user->hasPermission(
            'modify',
            'extension/payment/citruspay'
        )) {
            $this->error['warning'] =
                $this->language->get('error_permission');
        }

        return !$this->error;
    }
}

后台表单至少需要提供以下配置:

payment_citruspay_merchant_id
payment_citruspay_secret_key
payment_citruspay_test
payment_citruspay_order_status_id
payment_citruspay_total
payment_citruspay_geo_zone_id
payment_citruspay_status
payment_citruspay_sort_order

具体需要哪些凭据,应以收到的 CitrusPay integration kit 为准,不要根据其他支付网关的字段猜测 CitrusPay 参数。

让支付方式出现在结账页面

前台模型需要返回支付方式信息。OpenCart 3.x 通常可以这样写:

<?php
class ModelExtensionPaymentCitruspay extends Model
{
    public function getMethod($address, $total)
    {
        if (!$this->config->get('payment_citruspay_status')) {
            return [];
        }

        $minimum = (float)$this->config->get('payment_citruspay_total');

        if ($minimum > 0 && $total < $minimum) {
            return [];
        }

        $this->load->language('extension/payment/citruspay');

        return [
            'code'       => 'citruspay',
            'title'      => $this->language->get('text_title'),
            'terms'      => '',
            'sort_order' => $this->config->get(
                'payment_citruspay_sort_order'
            )
        ];
    }
}

生产环境还需要处理 geo_zone_id。可以参考同一 OpenCart 版本中其他支付扩展的 getMethod(),通过 zone_to_geo_zone 表判断收货地址是否在允许区域内。

对应的前台语言文件可以写成:

<?php
$_['text_title'] = 'CitrusPay';

创建订单并跳转到 CitrusPay

客户确认支付后,控制器需要从 OpenCart 当前订单中读取金额、币种、账单信息和订单号,再按照 integration kit 的规则生成请求。

下面的代码只展示整体结构。gateway_url、请求字段和 generateSignature() 的实现,都必须替换成 CitrusPay 文档或 PHP integration kit 中的真实定义:

<?php
class ControllerExtensionPaymentCitruspay extends Controller
{
    public function index()
    {
        $this->load->model('checkout/order');

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

        if (!$orderInfo) {
            return;
        }

        $data['action'] = $this->getGatewayUrl();

        $data['fields'] = [
            'merchant_id' => $this->config->get(
                'payment_citruspay_merchant_id'
            ),
            'order_id'    => (string)$orderId,
            'amount'      => $this->formatGatewayAmount(
                $orderInfo['total'],
                $orderInfo['currency_code']
            ),
            'currency'    => $orderInfo['currency_code'],
            'customer_email' => $orderInfo['email'],
            'return_url'  => $this->url->link(
                'extension/payment/citruspay/callback',
                '',
                true
            )
        ];

        $data['fields']['signature'] = $this->generateSignature(
            $data['fields']
        );

        return $this->load->view(
            'extension/payment/citruspay',
            $data
        );
    }

    private function getGatewayUrl()
    {
        if ($this->config->get('payment_citruspay_test')) {
            return 'REPLACE_WITH_TEST_ENDPOINT';
        }

        return 'REPLACE_WITH_PRODUCTION_ENDPOINT';
    }

    private function formatGatewayAmount($amount, $currency)
    {
        // 按 integration kit 规定处理小数位和货币单位。
        return number_format((float)$amount, 2, '.', '');
    }

    private function generateSignature(array $fields)
    {
        $secret = $this->config->get(
            'payment_citruspay_secret_key'
        );

        /*
         * 必须使用 integration kit 指定的:
         * 1. 字段集合;
         * 2. 字段顺序;
         * 3. 拼接方式;
         * 4. 哈希或 HMAC 算法;
         * 5. 输出编码。
         */
        throw new LogicException(
            'Implement the CitrusPay signing specification'
        );
    }
}

模板负责提交表单:

<form action="{{ action }}" method="post" id="form-citruspay">
  {% for name, value in fields %}
    <input type="hidden"
           name="{{ name|e }}"
           value="{{ value|e }}">
  {% endfor %}

  <div class="buttons">
    <div class="pull-right">
      <button type="submit"
              class="btn btn-primary">
        Confirm Order
      </button>
    </div>
  </div>
</form>

这里没有填写真实的 CitrusPay URL、字段名和签名公式,因为不同的 integration kit 或账户接入版本可能采用不同协议。直接使用未经确认的参数,可能导致支付失败,也可能造成签名验证漏洞。

正确处理支付回调

支付网关显示支付成功页面,并不能证明订单已经可靠付款。回调处理需要验证签名、订单号、金额、币种和交易状态。必要时,还要通过服务端查询接口再次确认交易。

典型流程如下:

public function callback()
{
    $payload = $this->request->post;

    if (!$this->verifyCallbackSignature($payload)) {
        $this->log->write(
            'CitrusPay callback signature verification failed'
        );

        $this->response->addHeader(
            $this->request->server['SERVER_PROTOCOL']
            . ' 400 Bad Request'
        );
        return;
    }

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

    $this->load->model('checkout/order');
    $orderInfo = $this->model_checkout_order->getOrder($orderId);

    if (!$orderInfo) {
        $this->response->addHeader(
            $this->request->server['SERVER_PROTOCOL']
            . ' 404 Not Found'
        );
        return;
    }

    if (!$this->callbackMatchesOrder($payload, $orderInfo)) {
        $this->log->write(
            'CitrusPay callback order data mismatch: ' . $orderId
        );

        $this->response->addHeader(
            $this->request->server['SERVER_PROTOCOL']
            . ' 400 Bad Request'
        );
        return;
    }

    if ($this->isSuccessfulPayment($payload)) {
        if (!$orderInfo['order_status_id']) {
            $this->model_checkout_order->addOrderHistory(
                $orderId,
                (int)$this->config->get(
                    'payment_citruspay_order_status_id'
                ),
                'CitrusPay payment confirmed',
                true
            );
        }
    }

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

verifyCallbackSignature()、callbackMatchesOrder() 和 isSuccessfulPayment() 必须按照 integration kit 实现,不能只检查浏览器参数中是否存在类似 status=success 的值。

还需要区分两类地址:

  • return_url:客户完成付款后,浏览器返回的页面。
  • 服务端通知地址:CitrusPay 服务器主动发送交易结果的 webhook 或 callback。

如果 CitrusPay 接入协议同时支持这两种通知,应以通过签名验证的服务端通知或交易查询结果作为订单付款依据。客户可能在付款后关闭浏览器,浏览器返回请求也可能被伪造。

订单状态的处理建议

客户点击确认支付时,可以先将订单设为 “等待付款”。只有收到验证通过的成功通知后,才能将其改为 “处理中” 或店铺配置的已付款状态。

支付失败、客户取消或状态未知时,都不应把订单标记为已付款:

发起支付        -> Pending
验证成功        -> Processing 或店铺指定状态
明确支付失败    -> Failed
客户主动取消    -> Canceled
无法验证或未知  -> 保持 Pending,并记录日志

支付回调可能重复发送,因此处理逻辑必须具有幂等性。同一笔交易的通知多次到达时,不能重复扣减库存、发送邮件或累计积分。除了检查订单状态,最好保存 CitrusPay 的交易编号,并为该编号设置唯一约束或进行重复检查。

集成 PHP kit 时的注意事项

不要把整个 integration kit 直接复制到 catalog/controller。可以按以下方式处理:

  1. 将 SDK 放入扩展自己的库目录,或者通过 Composer 安装官方包。
  2. 在控制器或独立服务类中加载 SDK。
  3. 将商户密钥保存在 OpenCart 配置中,并限制后台配置权限。
  4. 前台模板只输出非敏感字段。
  5. 签名生成和验证都在服务器端完成。

如果 kit 中包含示例 index.php、成功页和失败页,需要把其中的逻辑拆分到 OpenCart 控制器和模板中,不能让示例程序绕过 OpenCart 的订单系统独立运行。

上线前必须验证

上线前至少需要测试以下场景:

  • 正常支付成功。
  • 客户取消支付。
  • 支付失败或余额不足。
  • 回调签名错误。
  • 回调金额或币种与订单不一致。
  • 同一回调重复发送。
  • 客户付款后没有返回店铺。
  • 回调早于浏览器返回。
  • 测试环境与生产环境的凭据相互隔离。
  • 使用 HTTPS 时,返回地址可以从公网访问。
  • 日志不会记录密钥、完整卡号或其他敏感支付数据。

如果商店使用的是 OpenCart 4.x,不能直接照搬上述文件结构。应先找到该版本自带的跳转型支付扩展,以其命名空间、安装机制、路由和模板路径为基础,再接入 CitrusPay kit。无论 OpenCart 版本如何变化,处理流程都是注册支付方式、生成支付请求、验证服务端回调并更新订单。

备注:内容仅供参考。