PrestaShop iFrame 支付模块应如何安全创建订单?
结论
不要修改 PaymentModule::validateOrder(),不要复制购物车,也不要把 id_customer 改成 0 来掩盖重复订单。
具体采用哪种设计,取决于支付平台是否要求预先提供 PrestaShop 的真实订单号:
- 如果支付平台接受商户侧交易号,不强制要求 PrestaShop 的
id_order,可以在支付前只创建模块自己的“支付意图”记录。确认支付成功后,再由服务端回调调用validateOrder()。这是首选方案。 - 如果支付平台要求在付款前提供真实的 PrestaShop 订单号,就用
validateOrder()创建一笔“等待支付”订单,并将付款前发送的订单确认邮件作为正常通知。支付成功后只更新订单状态,不能再次创建订单。
无论选择哪种方案,都应通过支付平台的服务端回调创建订单,并使用数据库唯一约束保证幂等。iframe 跳转和 order-confirmation 页面只负责显示结果,不能负责创建订单。
为什么不能依赖 iframe 跳转
iframe 中的前端流程并不可靠。客户可能关闭页面、断网或刷新页面,浏览器也可能阻止 iframe 跳转。即使页面显示“支付成功”,也不能证明服务器收到了可信的付款结果。
支付流程需要明确区分三类请求:
- iframe 页面:显示支付界面。
- 支付平台回调,也就是 webhook、IPN 或 server-to-server callback:确认支付,并创建或更新订单。
- 返回页面:查询最终状态并显示结果。
支付平台回调不依赖客户继续停留在页面上。即使客户在 5 秒内离开,也不会因此丢失订单。
推荐方案:先创建支付意图,成功后创建订单
如果网关接受自定义商户交易号,可以先在模块表中保存一条支付意图,不必提前创建 PrestaShop 订单。
建议至少保存以下信息:
id_payment_intent
id_cart
id_customer
secure_key
merchant_reference
gateway_transaction_id
amount
id_currency
status
id_order
created_at
updated_at
数据库中应建立唯一约束:
UNIQUE KEY uniq_cart (id_cart),
UNIQUE KEY uniq_merchant_reference (merchant_reference),
UNIQUE KEY uniq_gateway_transaction (gateway_transaction_id)
是否允许同一个购物车多次发起支付,应根据业务规则决定。如果允许重试,可以对“购物车 ID + 支付尝试编号”建立唯一约束,但同一次支付尝试只能对应一条记录。
完整流程
1. 打开支付页
模块根据当前购物车创建或读取支付意图:
- 确认购物车属于当前客户。
- 确认购物车尚未生成订单。
- 在服务器端重新计算金额和币种。
- 生成不可预测的
merchant_reference。 - 将支付意图写入模块表。
- 把交易号、金额、币种和回调地址发送给网关。
不要直接相信浏览器提交的金额、币种、客户 ID 或购物车 ID。
2. iframe 完成付款
iframe 可以显示成功页面,但前端此时只需轮询模块的状态查询接口。不要让 iframe 直接调用 validateOrder()。
3. 支付平台发送服务端回调
回调控制器需要执行以下检查:
- 验证签名、时间戳和随机数。
- 必要时主动向网关查询交易结果。
- 核对商户交易号。
- 核对网关交易号。
- 核对金额和币种。
- 检查支付意图的当前状态。
- 在数据库事务或行锁的保护下进行幂等处理。
确认交易真实成功后,才能调用核心方法:
<?php
$intent = $this->loadAndLockPaymentIntent($merchantReference);
if (!$intent) {
throw new Exception('Payment intent not found');
}
if ($intent['status'] === 'paid' && (int) $intent['id_order'] > 0) {
// 网关重复通知,直接返回成功。
return;
}
$cart = new Cart((int) $intent['id_cart']);
if (!Validate::isLoadedObject($cart)) {
throw new Exception('Cart not found');
}
$existingOrderId = (int) Order::getIdByCartId((int) $cart->id);
if ($existingOrderId > 0) {
// 订单可能已经在之前的回调中创建,但模块记录尚未更新。
$this->markIntentPaid(
(int) $intent['id_payment_intent'],
$existingOrderId,
$gatewayTransactionId
);
return;
}
$customer = new Customer((int) $cart->id_customer);
if (!Validate::isLoadedObject($customer)) {
throw new Exception('Customer not found');
}
$expectedAmount = (float) $cart->getOrderTotal(true, Cart::BOTH);
if (!$this->amountsAreEqual($expectedAmount, $paidAmount, $cart->id_currency)) {
throw new Exception('Payment amount mismatch');
}
$this->module->validateOrder(
(int) $cart->id,
(int) Configuration::get('PS_OS_PAYMENT'),
$expectedAmount,
$this->module->displayName,
null,
array('transaction_id' => $gatewayTransactionId),
(int) $cart->id_currency,
false,
$customer->secure_key
);
$orderId = (int) $this->module->currentOrder;
if ($orderId <= 0) {
$orderId = (int) Order::getIdByCartId((int) $cart->id);
}
if ($orderId <= 0) {
throw new Exception('Order creation failed');
}
$this->markIntentPaid(
(int) $intent['id_payment_intent'],
$orderId,
$gatewayTransactionId
);
这段示例只保留了核心结构。事务、锁和失败后的恢复方式,还要结合模块使用的数据库封装来实现。
不要用 PHP 浮点数直接比较货币金额。更安全的方式是按照币种精度,把金额转换成分等最小货币单位,再比较整数:
<?php
private function amountsAreEqual($expected, $received, $idCurrency)
{
$currency = new Currency((int) $idCurrency);
$precision = isset($currency->precision)
? (int) $currency->precision
: 2;
$factor = pow(10, $precision);
return (int) round($expected * $factor)
=== (int) round($received * $factor);
}
旧版 PrestaShop 使用的币种精度字段和舍入方式可能不同。模块应按照目标版本实际采用的价格精度和舍入规则进行适配,不能假定所有币种都固定保留两位小数。
必须提前创建 PrestaShop 订单时
如果网关协议明确要求在支付前提供真实的 id_order,就应在进入 iframe 前调用 validateOrder(),并把订单状态设为模块自己的“等待支付”状态:
<?php
$existingOrderId = (int) Order::getIdByCartId((int) $cart->id);
if ($existingOrderId <= 0) {
$this->module->validateOrder(
(int) $cart->id,
(int) Configuration::get('MYMODULE_OS_AWAITING_PAYMENT'),
(float) $cart->getOrderTotal(true, Cart::BOTH),
$this->module->displayName,
null,
array('payment_reference' => $merchantReference),
(int) $cart->id_currency,
false,
$customer->secure_key
);
$existingOrderId = (int) $this->module->currentOrder;
}
页面刷新时,应读取已有订单和支付意图,不能再次调用 validateOrder()。
仅在调用前执行 Order::getIdByCartId(),仍然无法彻底避免并发产生重复订单。两个请求可能同时查到“没有订单”,然后各自创建一笔订单。还需要采取以下措施:
- 为支付意图建立数据库唯一约束。
- 创建订单前锁定支付意图记录。
- 将支付意图状态以原子方式从
created更新为processing。 - 只有成功取得处理权的请求才能调用
validateOrder()。 - 回调重试时返回相同的成功结果。
支付成功后,只更新已有订单的状态:
<?php
$order = new Order((int) $orderId);
if (!Validate::isLoadedObject($order)) {
throw new Exception('Order not found');
}
$history = new OrderHistory();
$history->id_order = (int) $order->id;
$history->changeIdOrderState(
(int) Configuration::get('PS_OS_PAYMENT'),
$order
);
$history->addWithemail();
调用 addWithemail() 后是否发送邮件,取决于目标订单状态和相应邮件模板的配置。模块必须保证重复回调不会重复添加相同的状态历史,也不会重复发送邮件。
如果付款失败或超时,应将订单改为模块定义的失败、取消或过期状态。不要删除订单,也不要篡改订单的客户归属。
邮件应如何处理
在 PrestaShop 1.6 和 1.7 中,validateOrder() 都是支付模块创建正式订单的核心入口,但它没有一个能稳定适用于所有这些版本的公共参数,用于“创建订单但禁止所有邮件”。
订单状态中的 send_email 配置主要控制状态变更邮件,不能假定它一定会阻止 validateOrder() 流程发送订单确认邮件。不同小版本以及覆盖代码的实现也可能不同。
因此,可以从以下三种方式中选择。
首选:支付成功后才调用 validateOrder()
订单确认邮件会在确认支付后自然发出,不需要修改核心代码。
次选:接受“等待支付”的订单确认邮件
如果业务必须提前创建订单,这通常更符合 PrestaShop 的订单模型。邮件中应说明:
- 订单已经登记,但尚未支付。
- 商品是否保留,以及保留期限。
- 客户返回支付页时应使用哪个链接。
- 最终支付结果以订单状态为准。
不建议:复制或修改 validateOrder()
直接修改核心文件会带来这些风险:
- 升级时容易被覆盖。
- 不同 1.6.x、1.7.x 小版本的实现存在差异。
- 可能遗漏库存、购物车规则、发票、订单明细、多地址或多包裹逻辑。
- 并发处理和异常恢复更难维护。
也不要为了暂时阻止邮件而全局修改邮件配置。遇到并发请求时,这种做法可能误关其他订单的邮件。
如果业务明确要求提前创建正式订单,同时禁止发送任何邮件,就需要为每个受支持版本分别维护和测试兼容层。这种做法无法保证在所有 1.6.x 至 1.7.x 版本中通用,应视为与版本相关的定制功能。
为什么不应复制购物车
validateOrder() 通常不会直接从数据库中删除购物车,但订单创建后,这个购物车已经成为已下单购物车,不应再被用于创建新订单。客户会话中的当前购物车,也可能被后续流程清理或替换。
复制购物车可能造成:
- 购物车规则被重复使用。
- 库存被重复扣减。
- 定制商品、地址、配送方式或包裹信息不一致。
- 新旧购物车的金额在支付期间发生变化。
- 回调无法判断哪个购物车才是这笔交易的依据。
如果需要重试支付,应复用同一个支付意图和已有订单,或者明确创建一条新的支付尝试记录,不要暗中复制购物车。
不要把 id_customer 改成 0
id_customer 表示订单实际归属的客户。把它改成 0 无法解决重复订单,还会破坏订单、客户、地址、售后和后台查询之间的关联。
可能出现的问题包括:
- 客户无法在账户中正常查看订单。
- 后台无法正确找到对应客户。
- 退款、发票、客服和统计数据异常。
- 出现外键或第三方模块兼容性问题。
- 重复订单依然存在,只是被隐藏起来。
访客结账也不代表 id_customer = 0。PrestaShop 通常仍会为访客创建客户记录,只是该记录带有访客属性。
如果已经产生重复订单,应先查清每笔订单对应的支付交易。没有实际付款的重复订单,可以改为取消或支付失败状态。同一笔交易不能绑定到多个有效订单。
PrestaShop 1.6.x 至 1.7.x 的兼容策略
模块应通过 PaymentModule::validateOrder() 的公共方式调用,并尽量只传递目标版本共有的位置参数。不要使用 PHP 命名参数,也不要依赖某个小版本新增的内部参数。
可以把版本差异封装在模块内部:
<?php
private function createPaidOrder(
Cart $cart,
Customer $customer,
$amount,
$transactionId
) {
$this->validateOrder(
(int) $cart->id,
(int) Configuration::get('PS_OS_PAYMENT'),
(float) $amount,
$this->displayName,
null,
array('transaction_id' => $transactionId),
(int) $cart->id_currency,
false,
$customer->secure_key
);
$orderId = (int) $this->currentOrder;
if ($orderId <= 0) {
$orderId = (int) Order::getIdByCartId((int) $cart->id);
}
return $orderId;
}
声明支持哪些 PrestaShop 小版本,就必须逐一测试,至少要覆盖以下场景:
- 普通客户和访客结账。
- 使用折扣券的购物车。
- 不同币种和舍入方式。
- 运费、税费和免运费规则。
- 虚拟商品。
- 多地址或多包裹场景。
- 同一个回调重复发送。
- iframe 页面连续刷新。
- 多个回调并发到达。
validateOrder()成功,但模块状态更新失败后的恢复。- 支付成功,但浏览器始终没有返回商城。
- 支付失败、取消和超时。
安全与恢复要点
服务端回调还需要满足以下要求:
- 全程使用 HTTPS。
- 严格验证网关签名,并使用
hash_equals()比较签名。 - 检查回调时间戳,防止重放。
- 记录网关交易号,并为其建立唯一索引。
- 不把 iframe 中的 JavaScript 结果作为付款依据。
- 不信任回调请求中的金额,必须与服务器保存的金额核对。
- 记录必要的支付日志,但不要保存卡号、CVV、完整密钥或敏感身份数据。
- 向网关返回成功前,确认订单或支付意图状态已经持久化。
- 回调失败时返回可重试响应,同时保证重试不会重复创建订单。
order-confirmation页面只查询订单和支付意图的状态,不执行支付确认。
如果 iframe 加载的是第三方支付页面,还应检查支付平台对 iframe、第三方 Cookie、Content-Security-Policy 和 frame-ancestors 的要求。涉及银行卡数据时,应尽量使用支付平台托管的 iframe,避免卡数据进入商城服务器,以免扩大 PCI DSS 合规范围。
最终,支付意图或订单应作为服务端状态机管理:
created -> pending -> paid
-> failed
-> cancelled
-> expired
状态转换必须单向、可审计并且幂等。浏览器负责显示,服务端回调负责确认,数据库唯一约束负责阻止重复处理。这样可以一并处理页面刷新、客户提前离开、重复回调、购物车状态和邮件发送时机等问题。
备注:内容仅供参考。
