如何在 CS Cart 4 中实现 Mobile Payment Interface?
结论
不要把现有的 PHP 片段直接嵌入结账模板。应将 Mobile Payment 做成独立的 CS-Cart 支付处理器,再通过自定义 Add-on 提供收据码提交页面和验证控制器。
整个流程可以这样设计:
- 顾客选择 Mobile Payment 并提交订单。
- CS-Cart 创建一个“待付款”订单。
- 支付处理器将顾客跳转到收据码输入页面。
- 后端根据订单金额、商户号码和收据码,向移动支付平台验证交易。
- 验证成功后,调用 CS-Cart 的订单支付接口,将订单更新为已支付。
- 如果验证失败,订单继续保持待付款状态,并允许顾客重新输入。
现有代码只检查收据是否存在,无法识别重复使用、金额不符或伪造请求,不能直接用作完整的支付确认逻辑。
建议的目录结构
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 后,需要在后台创建或注册支付方式:
- 打开支付方式管理页面。
- 新建
Pay via Mobile。 - 将处理器指向
mobile_payment.php。 - 配置商户号码、API 地址、API 密钥和成功订单状态。
- 确保付款前的订单处于 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 输出支付结果。
备注:内容仅供参考。
