Prestashop payment module 如何接收外部 POST 请求?
结论
建议使用 PrestaShop 的 ModuleFrontController 创建独立的支付回调入口,例如:
https://shop.example.com/module/qrmobilepay/notification
不要把回调写在模块主类、主题文件或普通 PHP 文件中。Front Controller 会正确加载 PrestaShop 环境、模块实例和数据库,也能适配友好 URL、HTTPS 以及不同的安装目录。
这个接口应按服务器回调来处理。它不能依赖登录状态、Cookie 或浏览器跳转,只能接受经过签名验证,并向 Provider 核验过的支付结果。
创建回调 Controller
假设模块技术名称为 qrmobilepay,创建以下文件:
modules/qrmobilepay/controllers/front/notification.php
基础实现如下:
<?php
class QrmobilepayNotificationModuleFrontController extends ModuleFrontController
{
public $ssl = true;
public function postProcess()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
$this->respond(405, 'Method Not Allowed');
}
$requestString = isset($_POST['requestString'])
? trim((string) $_POST['requestString'])
: '';
if ($requestString === '') {
$this->respond(400, 'Missing requestString');
}
try {
$result = $this->module->handleProviderNotification(
$requestString,
$_SERVER
);
if (!$result) {
$this->respond(400, 'Invalid notification');
}
$this->respond(200, 'OK');
} catch (Exception $exception) {
PrestaShopLogger::addLog(
'qrmobilepay callback error: ' . $exception->getMessage(),
3
);
$this->respond(500, 'Internal Server Error');
}
}
private function respond($statusCode, $message)
{
http_response_code($statusCode);
header('Content-Type: text/plain; charset=utf-8');
exit($message);
}
}
Controller 只处理几件事:接收请求、检查请求方法、调用模块业务逻辑,并返回 Provider 要求的响应。签名验证、订单查询和状态更新应放在模块类或独立服务中。
获取正确的回调地址
不要手工拼接 /module/... 地址,应由 PrestaShop 生成 URL:
$callbackUrl = $this->context->link->getModuleLink(
$this->name,
'notification',
array(),
true
);
生成结果通常类似:
https://shop.example.com/module/qrmobilepay/notification
实际 URL 会受到 PrestaShop 安装目录、友好 URL 和路由配置的影响,因此应把 getModuleLink() 返回的完整地址配置到 Provider 后台。
如果访问 URL 时返回 404,可以清理 PrestaShop 缓存,再到“流量与 SEO”中重新保存一次友好 URL 设置。
正确读取 Provider 的数据
示例请求如下:
/?requestString=QP%3A1.0*BID%3AMYBID AM%3A6.00
需要先确认 Provider 的协议。这个例子中有两个容易混淆的问题:
- 参数放在 URL 查询字符串中时,它就是 GET 参数,即使 HTTP 请求方法是 POST。
- 只有 Provider 使用
application/x-www-form-urlencoded提交表单时,参数才会出现在$_POST中。
建议让 Provider 把数据作为表单字段发送:
POST /module/qrmobilepay/notification HTTP/1.1
Content-Type: application/x-www-form-urlencoded
requestString=QP%3A1.0%2ABID%3AMYBID%2AAM%3A6.00
此时可以这样读取:
$requestString = isset($_POST['requestString'])
? (string) $_POST['requestString']
: '';
如果 Provider 明确要求把参数放在 URL 查询字符串中,则应读取:
$requestString = isset($_GET['requestString'])
? (string) $_GET['requestString']
: '';
Tools::getValue('requestString') 可以同时读取 GET 和 POST。不过,支付回调最好明确数据来自哪里,以免 GET 参数覆盖或混淆 POST 数据。
如果 Provider 发送 JSON:
$rawBody = file_get_contents('php://input');
$payload = json_decode($rawBody, true);
if (!is_array($payload) || !isset($payload['requestString'])) {
$this->respond(400, 'Invalid JSON payload');
}
$requestString = (string) $payload['requestString'];
如果签名是根据原始请求体计算的,必须保留 $rawBody。不能先解码,再重新编码后验签。
不要根据示例猜测字段格式
URL 解码后,示例大致如下:
QP:1.0*BID:MYBID AM:6.00
BID、AM 分别表示什么,以及字段究竟使用 * 还是空格分隔,都必须以 Provider 的协议文档为准。不要只看这个示例就自行调用 explode('*', ...)。如果字段值本身包含分隔符,这种处理方式可能会解析错误。
更稳妥的方式是使用 Provider 提供的 SDK 或官方解析规则。解析完成后,还应确认以下内容:
- 支付状态明确为成功。
- Provider 交易号存在。
- 商户订单号可以映射到本地订单。
- 币种一致。
- 金额与本地订单金额一致。
- 同一交易尚未处理。
验证回调真实性
收到 requestString 并不能证明付款成功。任何人都可以向公开的回调 URL 发送 POST 请求。
如果 Provider 支持签名,应严格按照其协议验签。下面只是一个 HMAC 验签结构示例,其中的 Header 名称、算法和签名编码都必须替换为 Provider 的真实规范:
$rawBody = file_get_contents('php://input');
$receivedSignature = isset($_SERVER['HTTP_X_SIGNATURE'])
? (string) $_SERVER['HTTP_X_SIGNATURE']
: '';
$expectedSignature = hash_hmac(
'sha256',
$rawBody,
Configuration::get('QRMOBILEPAY_WEBHOOK_SECRET')
);
if (
$receivedSignature === '' ||
!hash_equals($expectedSignature, $receivedSignature)
) {
$this->respond(401, 'Invalid signature');
}
如果 Provider 没有可靠的签名机制,应使用通知中的交易号调用 Provider 的服务端查询接口,然后根据查询结果更新订单。IP 白名单可以作为辅助措施,但不能作为唯一的验证手段。
更新 PrestaShop 订单状态
发起支付时,需要保存本地订单或购物车与 Provider 交易之间的映射。收到回调后,应通过商户订单号或交易号查找记录,不要依赖:
$this->context->cart
$this->context->customer
$this->context->cookie
Provider 的服务器请求通常不包含顾客 Cookie,因此这些上下文可能为空,也可能属于错误的会话。
处理逻辑可以放在模块类中:
public function handleProviderNotification($requestString, array $server)
{
$payment = $this->parseProviderRequest($requestString);
if (!$this->verifyProviderPayment($payment, $server)) {
return false;
}
$order = Order::getByReference($payment['merchant_reference'])
->getFirst();
if (!Validate::isLoadedObject($order)) {
throw new Exception('Order not found');
}
if ((string) $order->id_currency !== (string) $payment['currency_id']) {
throw new Exception('Currency mismatch');
}
if (abs((float) $order->total_paid - (float) $payment['amount']) > 0.00001) {
throw new Exception('Amount mismatch');
}
if ($this->isTransactionProcessed($payment['transaction_id'])) {
return true;
}
$this->saveTransaction($order, $payment);
$history = new OrderHistory();
$history->id_order = (int) $order->id;
$history->changeIdOrderState(
(int) Configuration::get('PS_OS_PAYMENT'),
(int) $order->id
);
$history->addWithemail();
return true;
}
parseProviderRequest()、verifyProviderPayment()、isTransactionProcessed() 和 saveTransaction() 需要根据 Provider 协议及模块的数据表实现。示例中的 currency_id 也只是用来展示结构。如果 Provider 返回 ISO 币种代码,应按照实际协议进行转换和比较。
如果支付前还没有创建 PrestaShop 订单,可以在验证成功后调用支付模块的 validateOrder()。如果订单已经存在,就不要在每次回调时重复调用 validateOrder(),而应更新现有订单的状态。
必须保证幂等
如果出现超时、网络错误,或者 Provider 没有收到预期响应,它可能会重复发送同一条通知。因此,回调处理必须保证同一个 Provider 交易号只会入账一次。
建议在模块交易表中为 Provider 交易号建立唯一索引,并按以下顺序处理:
- 验证签名或查询 Provider。
- 查找对应的本地订单。
- 核对金额、币种和商户订单号。
- 检查交易号是否已经处理。
- 在数据库事务中保存交易并更新订单。
- 成功后返回 Provider 规定的确认内容。
对于已经处理过的合法通知,通常仍应返回 200 OK,否则 Provider 可能会持续重试。
还需要确认 Provider 对响应正文的具体要求。有些平台接受 OK,有些要求返回 JSON、固定字符串或特定状态码。成功响应必须严格遵循 Provider 的协议,不能自行假定。
备注:内容仅供参考。
