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 只是浏览器的跳转地址,不是支付凭证,任何人都可以手动访问。
推荐的支付流程
- 创建本地订单,将状态设为
pending,并保存正确的金额和币种。 - 调用 Saferpay Payment Page Initialize API。
- 保存接口返回的
Token,并将其与本地订单绑定。 - 将客户重定向到 Saferpay 返回的
RedirectUrl。 - 客户返回
success.php后,通过随机state或当前会话找到本地订单。 - 从数据库读取该订单对应的 Saferpay
Token。 - 由服务器调用 Payment Page Assert API。
- 核对 Saferpay 返回的以下字段:
Transaction.OrderIdTransaction.Amount.ValueTransaction.Amount.CurrencyCodeTransaction.StatusTransaction.Id
- 如果交易状态只是
AUTHORIZED,并且集成没有配置自动 Capture,则调用 Transaction Capture API。 - 确认交易达到
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,订单状态仍可得到最终确认。
备注:内容仅供参考。
