PAYATHON 2026

payment gateway 跳转支付的安全验证与 session 失效处理

支付阿杰

结论

不要信任用户浏览器提交给 paymentProcessed.php 的任何“支付成功”信息,包括订单号、金额、随机值和支付状态。浏览器返回页只能用来展示结果或引导用户重新登录。真正的支付确认必须来自以下至少一种可信渠道:

  1. payment gateway 直接发送的 webhook / server-to-server callback,并且服务端完成签名验证。
  2. 服务器根据 gateway 返回的交易标识,主动调用 gateway API 查询交易状态。
  3. 某些 gateway 会返回经过数字签名的参数,服务端必须严格按照官方协议验签。

订单状态和交付逻辑应以数据库中的订单记录和服务端确认结果为准,不能依赖 PHP session。随机值可以帮助定位订单,降低订单号被枚举的风险,但不能证明用户已经付款。

为什么随机值和返回金额不能证明支付成功

假设重定向到支付页面时携带以下参数:

order_id=12345
token=random-value
amount=99.00

这些值通常会经过用户浏览器。用户可以查看并保存参数,然后绕过支付页面,直接构造请求:

POST /paymentProcessed.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded

order_id=12345&token=random-value&amount=99.00&status=paid

即使 token 足够随机,只要它曾经暴露给浏览器,拿到它的人就能原样提交。它只能说明请求者知道这个订单的返回凭证,不能说明 payment gateway 已经收到款项。

返回的 amount 同样不能直接信任。正确金额应来自:

  • 商户数据库中创建订单时保存的应付金额;
  • gateway 通过可信回调或查询 API 返回的实收金额。

确认支付时,需要同时核对订单号、商户账号、币种、金额、交易状态和 gateway 交易编号。

推荐的支付流程

1. 在服务器端创建订单

用户点击付款后,服务器根据商品价格、优惠规则、税费和运费计算最终金额。不能把前端提交的总价直接作为订单金额。

建议至少保存以下字段:

CREATE TABLE orders (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT UNSIGNED NULL,
    public_token CHAR(64) NOT NULL UNIQUE,
    amount_minor BIGINT UNSIGNED NOT NULL,
    currency CHAR(3) NOT NULL,
    status VARCHAR(32) NOT NULL,
    gateway_transaction_id VARCHAR(128) NULL,
    paid_at DATETIME NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uq_gateway_transaction (gateway_transaction_id)
);

金额最好用最小货币单位保存。例如,人民币 99.00 元应保存为 9900 分,以免出现浮点数比较问题。

订单初始状态可以设为:

pending

public_token 应由密码学安全的随机数生成:

$publicToken = bin2hex(random_bytes(32));

这个 token 可以用来安全地定位返回页面中的订单,但不能作为付款证明。

2. 创建 gateway 支付请求

服务器向 gateway 提交:

  • 商户订单号;
  • 应付金额;
  • 币种;
  • 回调地址;
  • 用户返回地址;
  • gateway 要求的其他参数。

如果 gateway 提供服务端创建支付会话的 API,应优先使用,并保存返回的 checkout ID 或 payment ID。

签名算法、参数顺序和编码规则必须完全遵循所用 gateway 的官方文档,不要自行设计一套看起来类似的签名协议。

3. 区分浏览器返回地址和 webhook 地址

建议设置两个独立入口:

GET  /payment/return
POST /payment/webhook

两者的职责不同:

入口请求来源用途能否触发交付
/payment/return用户浏览器展示“处理中”或订单状态不能单独触发
/payment/webhookgateway 服务器验签并更新支付状态可以
gateway 查询 API商户服务器主动请求补充或复核支付结果可以

用户付款成功后可能直接关闭页面。浏览器返回请求也可能因广告拦截器、网络故障或用户操作而中断,所以不能把“用户成功返回网站”当作付款完成的必要条件。

webhook 应如何验证

不同 gateway 的验签方式不同,常见方式包括 HMAC、RSA/ECDSA 数字签名,也可能由官方 SDK 完成验证。下面只展示通用结构,不能替代具体 gateway 的官方实现:

<?php

$rawBody = file_get_contents('php://input');
$receivedSignature = $_SERVER['HTTP_X_GATEWAY_SIGNATURE'] ?? '';

$secret = getenv('PAYMENT_WEBHOOK_SECRET');

$expectedSignature = hash_hmac('sha256', $rawBody, $secret);

if (
    $receivedSignature === '' ||
    !hash_equals($expectedSignature, $receivedSignature)
) {
    http_response_code(400);
    exit('Invalid signature');
}

$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);

需要注意:

  • 验签通常必须使用未经修改的原始请求体;
  • 不要先解析 JSON,再重新编码后验签;
  • 密钥不能提交到代码仓库,也不能写入日志;
  • header 名称、摘要格式、时间戳和签名内容必须以 gateway 文档为准;
  • 有些 gateway 会把时间戳纳入签名,此时需要检查允许的时间偏差,防止重放;
  • 如果 gateway 提供官方 SDK,通常应优先使用 SDK 的 webhook 验证函数。

IP 白名单可以作为附加限制,但不能代替数字签名。服务商 IP 可能变化,代理配置也可能引起误判。

安全地更新订单状态

验签成功只代表事件来自可信来源,不代表该事件一定表示支付完成。服务端还要检查事件类型和交易字段。

下面是一个简化的处理结构:

<?php

$orderId = (int) $event['metadata']['order_id'];
$transactionId = (string) $event['transaction_id'];
$eventId = (string) $event['event_id'];
$status = (string) $event['status'];
$paidAmountMinor = (int) $event['amount_minor'];
$currency = strtoupper((string) $event['currency']);

$pdo->beginTransaction();

try {
    $eventStmt = $pdo->prepare(
        'INSERT IGNORE INTO payment_events
         (gateway_event_id, payload, created_at)
         VALUES (:event_id, :payload, NOW())'
    );

    $eventStmt->execute([
        ':event_id' => $eventId,
        ':payload' => $rawBody,
    ]);

    if ($eventStmt->rowCount() === 0) {
        $pdo->commit();
        http_response_code(200);
        exit('Already processed');
    }

    $orderStmt = $pdo->prepare(
        'SELECT id, amount_minor, currency, status
         FROM orders
         WHERE id = :id
         FOR UPDATE'
    );

    $orderStmt->execute([':id' => $orderId]);
    $order = $orderStmt->fetch(PDO::FETCH_ASSOC);

    if (!$order) {
        throw new RuntimeException('Order not found');
    }

    if (
        $status !== 'paid' ||
        (int) $order['amount_minor'] !== $paidAmountMinor ||
        strtoupper($order['currency']) !== $currency
    ) {
        throw new RuntimeException('Payment data mismatch');
    }

    if ($order['status'] === 'pending') {
        $updateStmt = $pdo->prepare(
            'UPDATE orders
             SET status = :status,
                 gateway_transaction_id = :transaction_id,
                 paid_at = NOW(),
                 updated_at = NOW()
             WHERE id = :id AND status = :expected_status'
        );

        $updateStmt->execute([
            ':status' => 'paid',
            ':transaction_id' => $transactionId,
            ':id' => $orderId,
            ':expected_status' => 'pending',
        ]);
    }

    $pdo->commit();
    http_response_code(200);
    echo 'OK';
} catch (Throwable $e) {
    $pdo->rollBack();

    error_log('Payment webhook failed: ' . $e->getMessage());
    http_response_code(500);
    echo 'Retry later';
}

示例中的 payment_events.gateway_event_id 应设置唯一索引:

CREATE TABLE payment_events (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    gateway_event_id VARCHAR(128) NOT NULL,
    payload JSON NOT NULL,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uq_gateway_event (gateway_event_id)
);

实际接入时,还要核对 gateway 返回的商户账号、支付对象或 checkout ID,防止其他商户或其他支付会话的交易被错误关联到当前订单。

必须保证幂等性

gateway 可能因为超时或没有收到 2xx 响应,多次发送同一个通知。用户也可能反复刷新返回页。因此,支付处理必须保证幂等:

  • 同一个 gateway event ID 只能处理一次;
  • 同一个 gateway transaction ID 不能绑定到多个订单;
  • 已经处于 paid 状态的订单再次收到成功通知时,不能重复发货;
  • 发放许可证、充值余额、发送兑换码等操作也必须有唯一的业务记录;
  • 不要采用“先查询是否处理,再插入”的无锁逻辑,否则并发请求可能同时通过检查。

对于数字商品,最好把“订单变为已付款”和“执行交付”分开处理。支付事务中只创建一条待交付任务,再由后台任务执行实际交付。这样即使邮件、库存或第三方接口暂时失败,付款记录也不会丢失。

session 过期应该如何处理

支付确认不能依赖用户 session。用户在 gateway 页面停留时间较长、切换设备,或者浏览器阻止 Cookie,都可能导致原 session 失效,但这不影响服务端确认付款。

浏览器返回页可以接收订单的 public_token:

https://shop.example.com/payment/return?order=随机且不可猜测的值

然后只展示有限的信息:

<?php

$token = $_GET['order'] ?? '';

if (!preg_match('/\A[a-f0-9]{64}\z/', $token)) {
    http_response_code(400);
    exit('Invalid order reference');
}

$stmt = $pdo->prepare(
    'SELECT status FROM orders WHERE public_token = :token'
);
$stmt->execute([':token' => $token]);

$order = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$order) {
    http_response_code(404);
    exit('Order not found');
}

if ($order['status'] === 'paid') {
    echo '付款已确认。';
} else {
    echo '付款结果正在确认,请稍后刷新订单页面。';
}

这个页面不应仅凭 token 提供高价值内容、完整个人资料或永久下载权限。更稳妥的做法是:

  • session 仍然有效时,先确认订单属于当前用户,再显示完整订单;
  • session 已过期时,只显示不敏感的支付状态,并要求用户重新登录;
  • 用户重新登录后,根据订单的 user_id 检查归属;
  • 游客订单可以使用一次性领取链接或经过验证的邮箱流程;
  • 下载商品时,应使用短期、一次性或有次数限制的下载凭证。

即使返回页显示“成功”,订单的最终状态仍应由 webhook 或服务器查询结果决定,不能依据 URL 参数。

当 webhook 延迟或丢失时

webhook 通常会有几秒到数分钟的延迟。用户可能已经回到网站,但 webhook 还没有到达。这时返回页应显示“正在确认”,不要马上判定支付失败。

可以采用以下补偿机制:

  1. 返回页只读取数据库状态。
  2. 对长时间停留在 pending 状态的订单,由服务器调用 gateway API 查询。
  3. 使用定时任务对异常订单进行对账。
  4. 查询结果和 webhook 使用同一套订单状态机处理,避免形成两套逻辑。
  5. 如果 gateway 要求 webhook 快速响应,可以先持久化事件并返回 2xx,再异步处理耗时业务。

主动调用查询 API 时,应使用服务器保存的 payment ID 或 transaction ID,并通过服务端凭据发起请求。不要让浏览器代替服务器查询,也不要信任浏览器提交的查询结果。

订单状态不要只有成功和失败

支付过程可能包括授权、捕获、取消、退款和争议等阶段。具体状态取决于 gateway,但内部状态至少可以考虑:

pending
authorized
paid
failed
cancelled
partially_refunded
refunded
chargeback

系统必须定义允许的状态转换。例如:

pending -> paid
pending -> failed
paid -> partially_refunded
paid -> refunded
paid -> chargeback

收到延迟或乱序事件时,不能让旧事件把订单从 paid 改回 pending。如果业务采用“先授权、后捕获”,也不能把 authorized 直接视为最终收款完成。

其他容易遗漏的问题

金额与币种

  • 使用整数形式的最小货币单位,不要用 float 比较金额;
  • 同时核对金额和币种;
  • 金额以创建订单时服务器保存的值为准;
  • 部分支付、分次捕获、手续费和退款金额不一定等于订单总额;
  • 不要把用户返回的 total 当作可信数据。

重放与重复交付

攻击者可能重复发送一份曾经真实有效的通知。签名只能证明通知来自 gateway,不能自动阻止重复处理。因此,系统还必须记录 event ID 和 transaction ID,并使用数据库唯一约束和事务。

订单归属

支付通知用于确认“哪一个订单已付款”,session 用于确认“当前访问者是谁”。这是两个不同的问题。不能因为某个订单已经付款,就允许任何知道顺序订单号的人查看地址、发票或下载商品。

日志和敏感信息

  • 不要记录卡号、CVV、完整身份信息或 gateway 密钥;
  • 持久化 webhook payload 时,应根据实际字段进行脱敏并设置访问控制;
  • 日志中可以保留订单号、事件 ID、交易 ID 和错误原因;
  • 生产环境应关闭 PHP 错误页面,避免泄露配置、SQL 和密钥。

所有支付入口、返回页和 webhook 地址都应使用 HTTPS。登录 Cookie 通常应配置:

Secure
HttpOnly
SameSite=Lax

如果支付流程必须跨站发送 Cookie,需要结合实际的重定向和 POST 行为评估 SameSite 设置。不要为了简化支付返回流程而关闭必要的 Cookie 保护。

CSRF 与开放入口

webhook 通常不能使用传统的 session CSRF token,因为请求来自 gateway 服务器。它通过 webhook 签名完成身份认证。

如果浏览器返回页只读取状态,可以使用 GET。如果它能修改账户或订单数据,仍然需要正常的登录验证和 CSRF 防护。不能因为它是“支付返回页面”,就允许未经验证的请求修改状态。

超时和响应处理

gateway 往往要求 webhook 在限定时间内返回成功响应。数据库操作、发送邮件、生成文件或调用物流接口都可能拖慢响应,所以这些操作应尽量异步执行。

还要监控以下情况并设置告警:

  • webhook 连续验签失败;
  • 已付款金额与订单金额不一致;
  • 未知订单收到支付通知;
  • 同一个 transaction ID 对应多个订单;
  • 订单长时间停留在 pending;
  • 交付任务多次失败;
  • webhook 返回大量 5xx。

退款与拒付

“已支付”不代表交易永久有效。退款、撤销和 chargeback 通常也会通过 webhook 通知。系统需要保存支付流水,不能简单删除或覆盖原交易记录,并应根据业务规则撤销尚未使用的权益。

PCI DSS 与托管支付页

使用 gateway 托管的支付页面,通常可以减少系统直接接触银行卡数据的范围,但不代表自动免除全部 PCI DSS、安全配置或合规责任。具体要求取决于集成方式、数据流和服务商政策,应以所选 gateway 的官方集成文档及适用的合规要求为准。服务器不应接收或保存 CVV,也要避免让银行卡数据进入自己的日志、分析脚本或表单接口。

最稳妥的职责划分

整个流程可以归纳为:

浏览器返回页:改善用户体验
webhook:接收可信支付事件
查询 API:复核和补偿
数据库订单:保存最终业务状态
session:验证当前访问者身份
后台任务:执行可重试且幂等的交付

订单号和随机值方案可以保留,用于关联订单并降低枚举风险,但不能代替 gateway 验签或服务端交易查询。支付是否成功,必须由 gateway 提供的可信服务端证据决定。用户是否有权查看或领取订单,则应继续通过登录身份、订单归属或专门设计的一次性领取机制判断。

备注:内容仅供参考。