PAYATHON 2026

如何确保 online payment 只执行一次?

支付阿杰

结论

禁用按钮或添加遮罩层,无法保证付款只执行一次。浏览器可能重复提交,网络超时可能触发重试,用户也可能刷新页面,或者同时在多个标签页中操作。

可靠的方案需要两层防护:

  1. 在服务器和数据库中进行控制,确保同一购买意图只有一个请求能获得调用支付网关的资格。
  2. 使用支付网关提供的 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。此时数据库中没有对应记录,两个并发请求都可能通过“尚未购买”的检查,然后分别向网关发起扣款。

可以按以下流程处理:

  1. 用户打开结算页面时,由服务器创建购买订单。
  2. 为订单生成不可预测的唯一标识,例如 UUID。
  3. 提交支付时携带该订单标识,不要临时创建一笔没有身份的付款。
  4. 服务器通过原子操作把订单状态从 created 改为 processing。
  5. 只有成功完成状态转换的请求才能调用网关。
  6. 其他请求直接返回现有订单状态,不再发起付款。
  7. 网关确认成功后,把订单更新为 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,网关会将它们视为不同付款,幂等机制也就无法发挥作用。

如果网关只接收参考号,却不保证参考号唯一,该字段只能用于查询和对账,不能自动阻止重复扣款。

正确处理超时和未知结果

支付请求可能得到三类结果:

  1. 网关明确返回授权成功:把订单改为 paid。
  2. 网关明确返回拒绝:把订单改为 failed。
  3. 连接超时或响应丢失:把订单改为 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。这只能解决体验层的问题。真正防止重复扣款,仍要依靠数据库中的原子状态转换、固定订单标识,以及网关端的幂等或交易查询能力。

备注:内容仅供参考。