如何确保 online payment 只执行一次?
结论
禁用按钮或添加遮罩层,无法保证付款只执行一次。浏览器可能重复提交,网络超时可能触发重试,用户也可能刷新页面,或者同时在多个标签页中操作。
可靠的方案需要两层防护:
- 在服务器和数据库中进行控制,确保同一购买意图只有一个请求能获得调用支付网关的资格。
- 使用支付网关提供的 idempotency key、唯一商户订单号或重复交易检测,确保同一订单即使被重复发送,也只会扣款一次。
第一层能阻止绝大多数重复调用,但最棘手的情况只能靠第二层处理:网关已经扣款,服务器却没有收到响应。如果 Banorte 不支持幂等键、唯一订单号或交易状态查询,商户服务器就无法绝对保证只扣一次,只能依靠订单状态查询和对账来降低风险。
前端措施只能改善体验
禁用按钮有用,但不能作为安全边界。重复请求仍可能来自:
- 双击、按 Enter 或同时操作多个标签页;
- JavaScript 重复绑定事件;
- 页面刷新或重复提交历史 POST;
- 浏览器、反向代理或应用代码重试;
- 第一次请求超时后,用户再次尝试;
- 支付已经完成,但响应在返回途中丢失。
当前代码还可能针对多次 submit 事件分别创建定时器。更稳妥的做法是在第一次有效提交时立即锁定,并保存提交状态:
let submitting = false;
$(document).on("submit", "#card-form", function (e) {
e.preventDefault();
if (!cardSubmit || submitting) {
return;
}
submitting = true;
$("#process").addClass("loading");
$("#card-form button[type='submit']")
.prop("disabled", true)
.text("Processing...");
const form = this;
setTimeout(function () {
form.submit();
}, 2500);
});
这段代码能减少用户操作引起的重复提交。恶意请求、并发请求和网络重试仍要由服务器处理。
先创建订单,再调用支付网关
不要等网关返回授权成功后,才第一次创建 transaction。此时数据库中没有对应记录,两个并发请求都可能通过“尚未购买”的检查,然后分别向网关发起扣款。
可以按以下流程处理:
- 用户打开结算页面时,由服务器创建购买订单。
- 为订单生成不可预测的唯一标识,例如 UUID。
- 提交支付时携带该订单标识,不要临时创建一笔没有身份的付款。
- 服务器通过原子操作把订单状态从
created改为processing。 - 只有成功完成状态转换的请求才能调用网关。
- 其他请求直接返回现有订单状态,不再发起付款。
- 网关确认成功后,把订单更新为
paid。
订单状态可以这样设计:
created -> processing -> paid
-> failed
-> unknown
unknown 表示请求可能已经到达网关,但服务器没有得到明确结果。遇到这种情况,不应立即再次扣款,而要先向网关查询原订单的状态。
使用数据库约束解决并发竞争
先执行一次 SELECT,再根据查询结果决定是否付款,无法阻止并发请求:
请求 A:查询,没有记录
请求 B:查询,没有记录
请求 A:调用网关
请求 B:调用网关
需要让数据库通过唯一约束和原子状态转换,决定哪个请求可以继续。
如果业务规则确实是“每个用户对每个产品只能存在一个购买订单”,可以添加唯一约束:
CREATE UNIQUE INDEX uq_orders_customer_product
ON orders (customer_id, product_id);
然后通过原子更新抢占处理权:
UPDATE orders
SET status = 'processing',
processing_started_at = CURRENT_TIMESTAMP
WHERE id = :order_id
AND customer_id = :customer_id
AND status = 'created';
应用程序必须检查受影响的行数:
1:当前请求获得处理权,可以调用网关;0:订单已经被其他请求处理,不能再次调用网关。
PHP 示意代码如下:
$pdo->beginTransaction();
$stmt = $pdo->prepare(
"UPDATE orders
SET status = 'processing',
processing_started_at = CURRENT_TIMESTAMP
WHERE id = :order_id
AND customer_id = :customer_id
AND status = 'created'"
);
$stmt->execute([
'order_id' => $orderId,
'customer_id' => $customerId,
]);
$claimed = $stmt->rowCount() === 1;
$pdo->commit();
if (!$claimed) {
$order = loadOrder($orderId, $customerId);
// 返回当前订单状态,不再调用支付网关。
return renderExistingOrderResult($order);
}
不要在调用支付网关期间一直持有数据库事务或行锁。外部网络请求可能耗时,也可能超时。长事务会增加锁竞争。应先在短事务中完成状态抢占,提交事务后再调用网关。
将唯一订单号传给支付网关
需要查阅 Banorte 当前的接口文档,确认它是否支持以下任一能力:
- idempotency key;
- 唯一 merchant reference;
- merchant order ID;
- client transaction ID;
- 按商户订单号查询交易;
- 拒绝对重复订单号再次扣款。
字段名称和唯一性规则取决于实际使用的 Banorte 产品及接口版本,不能只根据示例代码判断。
如果接口支持,应为每次购买意图生成一个固定订单号,并在所有重试中复用:
$merchantOrderId = $order['payment_reference'];
$parametros = [
// Banorte 要求的其他字段
'merchant_order_id' => $merchantOrderId,
];
同一次购买必须始终使用同一个值。如果每次重试都生成新的 transaction ID,网关会将它们视为不同付款,幂等机制也就无法发挥作用。
如果网关只接收参考号,却不保证参考号唯一,该字段只能用于查询和对账,不能自动阻止重复扣款。
正确处理超时和未知结果
支付请求可能得到三类结果:
- 网关明确返回授权成功:把订单改为
paid。 - 网关明确返回拒绝:把订单改为
failed。 - 连接超时或响应丢失:把订单改为
unknown,不要直接重试扣款。
示意代码:
try {
$response = $client->post('/payw2', [
// 请依据 Banorte 文档确认应使用 query、form_params 还是 json。
'form_params' => $parametros,
'timeout' => 30,
'connect_timeout' => 10,
]);
$headers = $response->getHeaders();
$result = $headers['RESULTADO_PAYW'][0] ?? null;
if ($result === 'A') {
markOrderPaid(
$orderId,
getProviderTransactionId($response)
);
} else {
markOrderFailed($orderId, $result);
}
} catch (\GuzzleHttp\Exception\TransferException $e) {
markOrderUnknown($orderId);
// 此时不能断定付款失败,也不应立刻发起第二次扣款。
schedulePaymentStatusCheck($orderId);
}
原代码通过 Guzzle 的 query 选项,把参数放进 URL 查询字符串:
'query' => $parametros
如果 Banorte 要求使用表单编码的 POST body,应改用 form_params;如果要求 JSON,则使用 json。具体方式必须以实际接口文档为准。参数位置错误不一定会直接造成重复扣款,但可能影响签名、日志记录和网关行为。
分开记录商户订单与网关交易
建议至少维护两类数据:
orders:记录一次业务购买意图;payment_attempts:记录每次与网关通信的尝试及结果。
例如:
CREATE TABLE payment_attempts (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
request_key VARCHAR(64) NOT NULL,
provider_transaction_id VARCHAR(128),
status VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE (request_key),
UNIQUE (provider_transaction_id)
);
其中:
request_key用于防止应用重复处理同一次支付请求;provider_transaction_id用于防止同一网关交易的回调或通知被处理两次;- 唯一约束冲突应视为“已经处理”,而不是系统错误;
- 应完整记录请求时间、订单号、网关交易号、结果和异常,以便追踪间隔两分钟发生的重复付款。
不要记录完整卡号、CVV 或其他不应保存的敏感支付数据。具体的存储和日志策略还要符合适用的 PCI DSS 要求。
回调处理也必须幂等
即使支付请求只发送一次,支付网关的 webhook、异步通知或浏览器回跳仍可能重复到达。处理授权结果时,应使用条件更新:
UPDATE orders
SET status = 'paid',
provider_transaction_id = :provider_transaction_id,
paid_at = CURRENT_TIMESTAMP
WHERE id = :order_id
AND status <> 'paid';
发票、邮件、入场资格和库存扣减等后续操作也要避免重复执行。可以通过唯一业务事件或 outbox 记录来驱动这些操作,不要每收到一次回调就直接执行一遍。
一人限购一次时的额外考虑
UNIQUE (customer_id, product_id) 是最直接的限制,但需要先明确,未完成订单是否也会占用购买资格。如果用户创建订单后放弃付款,这项约束可能让他以后一直无法重试。
常见的处理方式有:
- 保留原订单,让用户继续完成支付;
- 为尚未调用网关的订单设置合理的过期时间;
- 对
unknown状态先查询网关或进行人工对账,不能直接等订单过期后重新扣款; - 为最终入场资格再设置一项唯一约束,确保不会发放两份权益。
订单唯一、扣款幂等和权益唯一是三个相互关联但并不相同的问题,最好分别设置约束。
推荐的完整处理顺序
创建或取得唯一订单
↓
数据库原子抢占 created -> processing
↓
使用固定商户订单号调用网关
↓
明确成功 ───────→ paid
明确拒绝 ───────→ failed
超时/断线 ──────→ unknown
↓
按订单号查询网关
↓
paid 或 failed
完成处理后,再使用 POST/Redirect/GET 模式跳转到结果页,可以避免用户刷新感谢页面时重新提交 POST。这只能解决体验层的问题。真正防止重复扣款,仍要依靠数据库中的原子状态转换、固定订单标识,以及网关端的幂等或交易查询能力。
备注:内容仅供参考。
