PAYATHON 2026

如何在 CS Cart 4 中实现 Mobile Payment Interface?

支付小周

结论

不要把现有的 PHP 片段直接嵌入结账模板。应将 Mobile Payment 做成独立的 CS-Cart 支付处理器,再通过自定义 Add-on 提供收据码提交页面和验证控制器。

整个流程可以这样设计:

  1. 顾客选择 Mobile Payment 并提交订单。
  2. CS-Cart 创建一个“待付款”订单。
  3. 支付处理器将顾客跳转到收据码输入页面。
  4. 后端根据订单金额、商户号码和收据码,向移动支付平台验证交易。
  5. 验证成功后,调用 CS-Cart 的订单支付接口,将订单更新为已支付。
  6. 如果验证失败,订单继续保持待付款状态,并允许顾客重新输入。

现有代码只检查收据是否存在,无法识别重复使用、金额不符或伪造请求,不能直接用作完整的支付确认逻辑。

建议的目录结构

CS-Cart 4 的具体目录和注册字段会因小版本而异。以下结构以常见的 app/addons 和 app/payments 为例:

app/
├── addons/
│   └── mobile_payment/
│       ├── addon.xml
│       ├── func.php
│       └── controllers/
│           └── frontend/
│               └── mobile_payment.php
└── payments/
    └── mobile_payment.php

design/
└── themes/
    └── responsive/
        └── templates/
            └── addons/
                └── mobile_payment/
                    └── views/
                        └── mobile_payment/
                            └── receipt.tpl

支付处理器通常还需要一个后台配置模板,用于保存商户号码、接口地址和 API 密钥等参数。不同 CS-Cart 4 版本的处理器注册方式和数据库字段可能不同,应以当前安装版本已有的支付 Add-on 为参照,不要直接复制其他版本的 SQL。

支付处理器

创建 app/payments/mobile_payment.php:

<?php

defined('BOOTSTRAP') or die('Access denied');

if (defined('PAYMENT_NOTIFICATION')) {
    return;
}

$order_info = fn_get_order_info($order_id);

if (empty($order_info)) {
    return;
}

/*
 * 此时订单已经创建,但尚未完成支付。
 * 不要在这里把订单直接标记为 Paid。
 */
$redirect_url = fn_url(
    'mobile_payment.receipt?order_id=' . (int) $order_id,
    AREA,
    'current'
);

return [CONTROLLER_STATUS_REDIRECT, $redirect_url];

如果当前 CS-Cart 小版本不支持由支付脚本返回控制器状态,可以改用:

fn_redirect($redirect_url);
exit;

具体写法应参考当前系统已有的离线支付或重定向支付处理器,因为 CS-Cart 4 各小版本执行支付脚本的方式存在差异。

收据码控制器

创建:

app/addons/mobile_payment/controllers/frontend/mobile_payment.php

示例代码:

<?php

defined('BOOTSTRAP') or die('Access denied');

use Tygh\Registry;

if ($mode === 'receipt') {
    $order_id = isset($_REQUEST['order_id'])
        ? (int) $_REQUEST['order_id']
        : 0;

    $order_info = fn_get_order_info($order_id);

    if (empty($order_info)) {
        return [CONTROLLER_STATUS_NO_PAGE];
    }

    /*
     * 必须检查当前用户是否有权操作该订单。
     * 已登录用户至少应匹配 user_id。
     * 游客订单建议使用一次性 token,而不是只依赖 order_id。
     */
    $auth = Tygh::$app['session']['auth'];

    if (
        !empty($order_info['user_id'])
        && (int) $order_info['user_id'] !== (int) $auth['user_id']
    ) {
        return [CONTROLLER_STATUS_DENIED];
    }

    Tygh::$app['view']->assign('order_info', $order_info);
}

if ($mode === 'verify' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    $order_id = isset($_REQUEST['order_id'])
        ? (int) $_REQUEST['order_id']
        : 0;

    $receipt = isset($_REQUEST['receipt'])
        ? trim((string) $_REQUEST['receipt'])
        : '';

    $order_info = fn_get_order_info($order_id);

    if (empty($order_info)) {
        return [CONTROLLER_STATUS_NO_PAGE];
    }

    if ($receipt === '') {
        fn_set_notification('E', __('error'), 'Please enter the transaction receipt.');
        return [
            CONTROLLER_STATUS_REDIRECT,
            'mobile_payment.receipt?order_id=' . $order_id
        ];
    }

    $result = fn_mobile_payment_verify_transaction(
        $receipt,
        (float) $order_info['total'],
        $order_id
    );

    if ($result['success']) {
        $pp_response = [
            'order_status'   => 'P',
            'reason_text'    => 'Mobile payment verified',
            'transaction_id' => $result['transaction_id'],
        ];

        fn_finish_payment($order_id, $pp_response);

        return [
            CONTROLLER_STATUS_REDIRECT,
            'checkout.complete?order_id=' . $order_id
        ];
    }

    fn_set_notification(
        'E',
        __('error'),
        $result['message'] ?: 'Payment could not be verified.'
    );

    return [
        CONTROLLER_STATUS_REDIRECT,
        'mobile_payment.receipt?order_id=' . $order_id
    ];
}

在通常的配置中,P 表示 Paid 状态。不过,店铺可能已经修改了状态代码或业务流程。生产环境应读取支付方式配置,或者使用专门为该支付方式设置的成功状态,不要无条件写死。

交易验证函数

在 app/addons/mobile_payment/func.php 中封装移动支付平台的查询逻辑:

<?php

defined('BOOTSTRAP') or die('Access denied');

function fn_mobile_payment_verify_transaction($receipt, $expected_amount, $order_id)
{
    $receipt = trim((string) $receipt);

    if (!preg_match('/^[A-Za-z0-9-]{6,64}$/', $receipt)) {
        return [
            'success' => false,
            'message' => 'Invalid transaction receipt format.',
        ];
    }

    /*
     * 先检查该收据是否已经被其他订单使用。
     * 表名仅为示例,需要在 Add-on 安装时创建。
     */
    $existing_order_id = db_get_field(
        'SELECT order_id FROM ?:mobile_payment_transactions'
        . ' WHERE receipt = ?s',
        $receipt
    );

    if ($existing_order_id && (int) $existing_order_id !== (int) $order_id) {
        return [
            'success' => false,
            'message' => 'This transaction receipt has already been used.',
        ];
    }

    /*
     * 用移动支付服务商提供的 API 或 SDK 查询交易。
     * locateByReceipt() 是题目中已有的函数,此处假设它返回交易列表。
     */
    $transactions = locateByReceipt($receipt);

    if (!is_array($transactions) || count($transactions) === 0) {
        return [
            'success' => false,
            'message' => 'Payment was not received. Check the receipt and try again.',
        ];
    }

    $transaction = reset($transactions);

    /*
     * 以下字段名称只是示意,必须替换为服务商真实返回的字段。
     */
    $transaction_id = isset($transaction['id'])
        ? (string) $transaction['id']
        : '';

    $paid_amount = isset($transaction['amount'])
        ? (float) $transaction['amount']
        : 0.0;

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

    $store_number = isset($transaction['receiver'])
        ? (string) $transaction['receiver']
        : '';

    $expected_store_number = Registry::get(
        'addons.mobile_payment.store_number'
    );

    if ($status !== 'completed') {
        return [
            'success' => false,
            'message' => 'The transaction has not been completed.',
        ];
    }

    if (abs($paid_amount - (float) $expected_amount) > 0.01) {
        return [
            'success' => false,
            'message' => 'The transaction amount does not match the order total.',
        ];
    }

    if (
        $expected_store_number !== null
        && $store_number !== (string) $expected_store_number
    ) {
        return [
            'success' => false,
            'message' => 'The transaction was sent to a different recipient.',
        ];
    }

    db_query(
        'INSERT INTO ?:mobile_payment_transactions'
        . ' ?e ON DUPLICATE KEY UPDATE ?u',
        [
            'receipt'        => $receipt,
            'transaction_id' => $transaction_id,
            'order_id'       => (int) $order_id,
            'amount'         => $paid_amount,
            'status'         => $status,
            'created_at'     => TIME,
        ],
        [
            'transaction_id' => $transaction_id,
            'status'         => $status,
        ]
    );

    return [
        'success'        => true,
        'transaction_id' => $transaction_id ?: $receipt,
        'message'        => '',
    ];
}

还要在文件顶部添加:

use Tygh\Registry;

如果 locateByReceipt() 查询的是本地短信或本地数据库,也要确认数据源是否可信。不能根据顾客提交的收据码自行构造“支付成功”记录。

收据输入模板

创建 receipt.tpl:

{capture name="mainbox"}

<h1>Pay via Mobile</h1>

<p>
    Please send {$order_info.total|format_price} to the store mobile number.
</p>

<form
    action="{"mobile_payment.verify"|fn_url}"
    method="post"
    name="mobile_payment_receipt_form"
>
    <input
        type="hidden"
        name="order_id"
        value="{$order_info.order_id|intval}"
    >

    <div class="control-group">
        <label class="control-label cm-required" for="mobile_payment_receipt">
            Transaction receipt
        </label>

        <div class="controls">
            <input
                type="text"
                id="mobile_payment_receipt"
                name="receipt"
                value=""
                maxlength="64"
                autocomplete="off"
            >
        </div>
    </div>

    <div class="buttons-container">
        {include
            file="buttons/button.tpl"
            but_name="dispatch[mobile_payment.verify]"
            but_text="Verify payment"
            but_role="submit"
        }
    </div>
</form>

{/capture}

{include
    file="common/mainbox.tpl"
    title="Mobile payment"
    content=$smarty.capture.mainbox
}

显示金额时还应带上货币代码,并确认服务商返回的金额与订单结算货币一致。

支付方式配置

安装 Add-on 后,需要在后台创建或注册支付方式:

  1. 打开支付方式管理页面。
  2. 新建 Pay via Mobile。
  3. 将处理器指向 mobile_payment.php。
  4. 配置商户号码、API 地址、API 密钥和成功订单状态。
  5. 确保付款前的订单处于 Open、Incomplete 或专门的 Awaiting payment 状态。

如果后台的处理器列表中没有这个处理器,Add-on 安装脚本需要将其注册到 ?:payment_processors。由于该表的字段可能随 CS-Cart 4 小版本变化,应先查看当前版本已有处理器的记录和 Add-on 安装方式,再按相同结构注册,避免使用未经确认的固定 SQL。

必须补上的安全检查

只做下面这项判断远远不够:

if (count($transactions) > 0) {
    echo 'Payment received';
}

至少还要检查:

  • 收据码是否真实存在;
  • 交易状态是否为最终成功状态;
  • 收款号码是否与店铺号码一致;
  • 实付金额是否与订单金额一致;
  • 交易币种是否一致;
  • 交易时间是否合理;
  • 收据码是否已被其他订单使用;
  • 当前顾客是否有权确认该订单;
  • 订单是否已经支付,以防重复确认;
  • 服务商响应是否带有可验证的签名;
  • API 密钥是否没有写入模板或前端 JavaScript。

数据库中的 receipt 和服务商 transaction_id 应设置唯一索引。这样即使顾客重复提交,或者两个请求同时到达,同一笔交易也不会被分配给两个订单。

更可靠的实现方式

如果移动支付服务商支持 Webhook,可以让顾客提交的收据码只用于关联订单,由服务商的服务器通知决定最终支付状态:

顾客提交收据码
        ↓
订单保持 Awaiting payment
        ↓
服务商 Webhook 通知店铺
        ↓
校验签名、金额、商户和交易编号
        ↓
fn_finish_payment()
        ↓
订单变为 Paid

Webhook 比单纯依赖顾客点击“验证支付”更可靠,也能处理顾客付款后直接关闭网页的情况。无论采用主动查询还是 Webhook,都应通过 CS-Cart 的 fn_finish_payment() 更新订单状态并记录支付信息。不要直接修改订单表,也不要只用 echo 输出支付结果。

备注:内容仅供参考。