PAYATHON 2026

Saferpay 支付成功后应如何安全设置 Success URL?

支付小周

明确结论

不能通过 success.php 是否被访问、Session、Return URL 或 $_SERVER['HTTP_REFERER'] 判断付款是否成功。

客户返回 success.php 后,服务器应使用初始化支付时由 Saferpay 返回的 Token,调用 Saferpay 的 Payment Page Assert API 查询交易。查询后,需要核对订单号、金额、币种和交易状态,并在需要时调用 Transaction Capture API。只有 Saferpay 的服务器端响应确认交易已达到预期的最终状态,才能把订单改为已付款。

Return URL 只是浏览器的跳转地址,不是支付凭证,任何人都可以手动访问。

推荐的支付流程

  1. 创建本地订单,将状态设为 pending,并保存正确的金额和币种。
  2. 调用 Saferpay Payment Page Initialize API。
  3. 保存接口返回的 Token,并将其与本地订单绑定。
  4. 将客户重定向到 Saferpay 返回的 RedirectUrl。
  5. 客户返回 success.php 后,通过随机 state 或当前会话找到本地订单。
  6. 从数据库读取该订单对应的 Saferpay Token。
  7. 由服务器调用 Payment Page Assert API。
  8. 核对 Saferpay 返回的以下字段:
    • Transaction.OrderId
    • Transaction.Amount.Value
    • Transaction.Amount.CurrencyCode
    • Transaction.Status
    • Transaction.Id
  9. 如果交易状态只是 AUTHORIZED,并且集成没有配置自动 Capture,则调用 Transaction Capture API。
  10. 确认交易达到 CAPTURED,或达到当前流程规定的其他最终成功状态后,再通过数据库事务更新订单。

具体请求字段和最终状态取决于所用的 Saferpay JSON API 版本及 Capture 配置,请以对应版本的接口文档为准。

初始化支付时保存 Token

不要只保存 RedirectUrl。Initialize API 返回的 Token 也必须保存,同时还应生成一个不可预测的返回标识:

<?php

$state = bin2hex(random_bytes(32));

// 创建本地订单
$stmt = $con->prepare(
    'INSERT INTO orders
        (Order_ID, Order_Status, Amount_Value, Currency_Code, Return_State)
     VALUES (?, ?, ?, ?, ?)'
);

$stmt->execute([
    $orderId,
    0,          // pending
    $amount,
    $currency,
    $state
]);

// 调用 Saferpay Payment Page Initialize API。
// $initializeResponse 是解码后的服务器响应。
$token       = $initializeResponse['Token'];
$redirectUrl = $initializeResponse['RedirectUrl'];

$stmt = $con->prepare(
    'UPDATE orders
     SET Saferpay_Token = ?
     WHERE Order_ID = ? AND Order_Status = 0'
);
$stmt->execute([$token, $orderId]);

header('Location: ' . $redirectUrl);
exit;

Initialize 请求中的 Success URL 可以带上这个随机 state:

https://shop.example.com/success.php?state=<random-value>

state 只能用于定位订单和防止返回标识被随意猜中,不能证明付款成功。付款结果仍须通过 Assert API 验证。

success.php 的安全实现示例

下面的示例展示了核心结构。接口地址、SpecVersion 和认证配置需要根据 Saferpay 环境及 API 版本填写。

<?php

declare(strict_types=1);

session_start();

$state = $_GET['state'] ?? '';

if (!preg_match('/^[a-f0-9]{64}$/', $state)) {
    http_response_code(400);
    exit('Invalid return state');
}

// 只能读取数据库中已经创建的 pending 订单。
$stmt = $con->prepare(
    'SELECT
        Order_ID,
        Order_Status,
        Amount_Value,
        Currency_Code,
        Saferpay_Token
     FROM orders
     WHERE Return_State = ?
     LIMIT 1'
);
$stmt->execute([$state]);

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

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

// 重复访问成功页时,不应重复 Capture 或发货。
if ((int) $order['Order_Status'] === 1) {
    header('Location: profile.php#profile-orders');
    exit;
}

$assertRequest = [
    'RequestHeader' => [
        'SpecVersion' => SAFERPAY_SPEC_VERSION,
        'CustomerId'  => SAFERPAY_CUSTOMER_ID,
        'RequestId'   => bin2hex(random_bytes(16))
    ],
    'Token' => $order['Saferpay_Token']
];

$assertResponse = saferpayPost(
    SAFERPAY_BASE_URL . '/Payment/v1/PaymentPage/Assert',
    $assertRequest
);

$transaction = $assertResponse['Transaction'] ?? null;

if (!is_array($transaction)) {
    http_response_code(502);
    exit('Unable to verify payment');
}

$returnedOrderId  = (string) ($transaction['OrderId'] ?? '');
$returnedAmount   = (string) ($transaction['Amount']['Value'] ?? '');
$returnedCurrency = (string) ($transaction['Amount']['CurrencyCode'] ?? '');
$status           = (string) ($transaction['Status'] ?? '');
$transactionId    = (string) ($transaction['Id'] ?? '');

// 不可信的浏览器参数不能参与这些比较。
// 所有预期值都来自数据库。
if (
    !hash_equals((string) $order['Order_ID'], $returnedOrderId) ||
    !hash_equals((string) $order['Amount_Value'], $returnedAmount) ||
    !hash_equals((string) $order['Currency_Code'], $returnedCurrency) ||
    $transactionId === ''
) {
    http_response_code(409);
    exit('Payment details do not match the order');
}

/*
 * 若 Assert 返回 AUTHORIZED,并且没有启用自动 Capture,
 * 此处需要调用 Transaction Capture API。
 */
if ($status === 'AUTHORIZED') {
    $captureRequest = [
        'RequestHeader' => [
            'SpecVersion' => SAFERPAY_SPEC_VERSION,
            'CustomerId'  => SAFERPAY_CUSTOMER_ID,
            'RequestId'   => bin2hex(random_bytes(16))
        ],
        'TransactionReference' => [
            'TransactionId' => $transactionId
        ]
    ];

    $captureResponse = saferpayPost(
        SAFERPAY_BASE_URL . '/Payment/v1/Transaction/Capture',
        $captureRequest
    );

    $status = (string) ($captureResponse['Status'] ?? '');
}

// 不要把 AUTHORIZED、PENDING 或浏览器到达成功页直接等同于已付款。
if ($status !== 'CAPTURED') {
    http_response_code(409);
    exit('Payment has not been captured');
}

try {
    $con->beginTransaction();

    /*
     * 条件更新使处理具备幂等性:
     * 只有尚未付款的订单能够从 0 变为 1。
     */
    $stmt = $con->prepare(
        'UPDATE orders
         SET
            Order_Status = 1,
            Saferpay_Transaction_ID = ?,
            Paid_At = CURRENT_TIMESTAMP
         WHERE Order_ID = ? AND Order_Status = 0'
    );

    $stmt->execute([
        $transactionId,
        $order['Order_ID']
    ]);

    if ($stmt->rowCount() === 1) {
        $stmt = $con->prepare(
            'UPDATE carts
             SET Cart_Status = 1
             WHERE Order_ID = ? AND Cart_Status = 0'
        );
        $stmt->execute([$order['Order_ID']]);
    }

    $con->commit();
} catch (Throwable $e) {
    if ($con->inTransaction()) {
        $con->rollBack();
    }

    error_log($e->getMessage());
    http_response_code(500);
    exit('Order update failed');
}

header('Location: profile.php#profile-orders');
exit;

示例中的 saferpayPost() 应通过 HTTPS 从服务器调用 Saferpay,并使用商户 API 凭据完成认证:

<?php

function saferpayPost(string $url, array $payload): array
{
    $ch = curl_init($url);

    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPAUTH       => CURLAUTH_BASIC,
        CURLOPT_USERPWD        => SAFERPAY_API_USERNAME . ':' . SAFERPAY_API_PASSWORD,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'Accept: application/json'
        ],
        CURLOPT_POSTFIELDS     => json_encode(
            $payload,
            JSON_THROW_ON_ERROR
        ),
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT        => 30
    ]);

    $body       = curl_exec($ch);
    $curlError  = curl_error($ch);
    $httpStatus = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

    curl_close($ch);

    if ($body === false || $curlError !== '') {
        throw new RuntimeException('Saferpay connection failed');
    }

    if ($httpStatus < 200 || $httpStatus >= 300) {
        throw new RuntimeException(
            'Saferpay returned HTTP status ' . $httpStatus
        );
    }

    return json_decode($body, true, 512, JSON_THROW_ON_ERROR);
}

生产环境还必须验证 TLS 证书,不要关闭 CURLOPT_SSL_VERIFYPEER 或 CURLOPT_SSL_VERIFYHOST。API 凭据只能保存在服务器端,不能写入浏览器、JavaScript、URL 或日志。

为什么不能使用 HTTP_REFERER

HTTP_REFERER 不能用于安全认证:

  • 浏览器可能不会发送这个请求头。
  • 隐私策略、代理和跳转过程可能删除或修改它。
  • 攻击者可以通过自建 HTTP 客户端伪造它。
  • 它只能说明页面来源,无法证明订单金额、币种和交易状态。
  • Saferpay 支付过程可能经过多个中间页面,因此它也不适合用来判断支付流程是否完成。

隐藏表单字段、Cookie、Session 中的”支付成功”标记,以及 URL 中的 paid=1,同样不能代替服务器端验证。

还需要注意的几个问题

不要在 fail.php 中立即删除订单

Fail、Abort 和 Success URL 都是浏览器入口。用户既可以伪造访问,也可能在付款完成后直接关闭浏览器,不再返回任何页面。

订单应该保留下来,并使用明确的状态,例如:

pending
authorized
paid
failed
cancelled
expired
refunded

对于长时间未支付的 pending 订单,可以由定时任务将其标记为 expired,不要让 fail.php 直接删除订单。

金额应使用最小货币单位或定点值

比较金额时不要使用浮点数。应按照 Saferpay 对 Amount.Value 的定义保存和比较金额,例如使用整数形式的最小货币单位,或精确的十进制字符串:

$returnedAmount === $order['Amount_Value']

防止重复处理和重复发货

用户可能刷新 success.php,Saferpay 也可能重试通知,因此数据库更新必须具备幂等性:

UPDATE orders
SET Order_Status = 1
WHERE Order_ID = ? AND Order_Status = 0

只有 rowCount() === 1 时,才能触发一次性的发货、邮件发送或库存扣减。还可以把发货任务写入带有唯一约束的任务表,再交给后台任务执行。

优先使用服务器通知补偿浏览器回跳

如果当前使用的 Saferpay 产品和 API 版本支持服务器端通知,应同时配置通知入口。收到通知后,仍须通过 Saferpay API 查询并核对交易,不能因为通知已经到达就直接把订单标记为已付款。

这样即使客户付款后关闭浏览器、网络中断,或没有返回 success.php,订单状态仍可得到最终确认。

备注:内容仅供参考。