PHP 中付款成功后如何安全进入注册流程?
结论
PHP Session 可以保存当前浏览器的注册状态,但不能单独用来证明用户已经付款。
付款应由支付平台的服务端回调(Webhook)确认,并将结果写入数据库。注册页面每次都要查询数据库,确认订单已付款、金额正确、资格尚未使用。Session 只需保存一个无法猜测的注册流程 ID,用于关联当前浏览器与订单。
不要根据付款后的跳转 URL、$_GET['success']、前端 JavaScript,或 Session 中单独保存的布尔值开放注册,因为这些信息都可能被伪造或劫持。
推荐流程
可以按以下流程实现:
- 用户付款前,服务端创建一条状态为
pending的注册意向记录。 - 将该记录的随机 ID 保存到 Session,并作为订单号或 metadata 传给支付平台。
- 支付平台扣款成功后,通过 Webhook 通知服务器。
- 服务器验证回调签名,并核对支付状态、订单号、金额、币种和商户信息。
- 验证通过后,将数据库中的状态更新为
paid。 - 用户返回网站时,
signup.php根据 Session 中的 ID 查询数据库。 - 只有记录状态为
paid、尚未使用且未过期,才允许显示或提交注册表单。 - 注册成功后,立即将付款记录标记为
consumed,防止一笔付款被重复使用。
支付平台的浏览器跳转与 Webhook 不一定按固定顺序到达。用户返回网站时,Webhook 可能还没有送达。此时,返回页面可以显示“正在确认付款”,并在短时间内轮询服务端状态,不要直接判定付款失败。
数据表设计
下面是一个最小示例,实际字段可根据业务需要调整:
CREATE TABLE registration_intents (
id CHAR(32) PRIMARY KEY,
gateway_payment_id VARCHAR(191) NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
amount DECIMAL(10, 2) NOT NULL,
currency CHAR(3) NOT NULL,
expires_at DATETIME NOT NULL,
consumed_at DATETIME NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uq_gateway_payment_id (gateway_payment_id)
);
id 必须使用安全随机数生成,不能采用连续数字,也不能把它视为付款证明。付款是否成功,仍以数据库中经过 Webhook 验证的记录为准。
创建付款流程
<?php
declare(strict_types=1);
session_set_cookie_params([
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
$intentId = bin2hex(random_bytes(16));
$stmt = $pdo->prepare(
'INSERT INTO registration_intents
(id, status, amount, currency, expires_at, created_at)
VALUES
(:id, :status, :amount, :currency, :expires_at, NOW())'
);
$stmt->execute([
'id' => $intentId,
'status' => 'pending',
'amount' => '99.00',
'currency' => 'CNY',
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
]);
$_SESSION['registration_intent_id'] = $intentId;
/*
* 调用支付平台 API 创建订单时:
*
* 1. 金额和币种必须由服务端确定;
* 2. 将 $intentId 放入平台支持的 order_id、reference 或 metadata;
* 3. 不要采用浏览器提交的金额。
*/
如果付款时需要跳转到第三方站点,SameSite=Lax 通常允许用户通过顶层导航返回网站时携带 Cookie。具体表现仍要结合实际支付流程和浏览器环境进行测试。
处理支付平台 Webhook
不同支付平台使用的签名算法和 SDK 并不相同。下面的代码只展示处理结构,签名验证必须严格按照所用平台的官方文档实现。
<?php
declare(strict_types=1);
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_GATEWAY_SIGNATURE'] ?? '';
if (!verifyGatewaySignature($rawBody, $signature)) {
http_response_code(400);
exit('Invalid signature');
}
$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
if (($event['type'] ?? '') !== 'payment.succeeded') {
http_response_code(200);
exit('Ignored');
}
$payment = $event['data'] ?? [];
$intentId = $payment['metadata']['registration_intent_id'] ?? null;
if (!is_string($intentId) || !preg_match('/^[a-f0-9]{32}$/', $intentId)) {
http_response_code(400);
exit('Invalid registration intent');
}
$paidAmount = $payment['amount'] ?? null;
$currency = $payment['currency'] ?? null;
$paymentId = $payment['payment_id'] ?? null;
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'SELECT id, status, amount, currency, expires_at
FROM registration_intents
WHERE id = :id
FOR UPDATE'
);
$stmt->execute(['id' => $intentId]);
$intent = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$intent) {
throw new RuntimeException('Unknown registration intent');
}
if (
!hash_equals((string) $intent['amount'], (string) $paidAmount) ||
!hash_equals($intent['currency'], (string) $currency)
) {
throw new RuntimeException('Payment details do not match');
}
if ($intent['status'] === 'pending') {
$stmt = $pdo->prepare(
'UPDATE registration_intents
SET status = :status, gateway_payment_id = :payment_id
WHERE id = :id'
);
$stmt->execute([
'status' => 'paid',
'payment_id' => $paymentId,
'id' => $intentId,
]);
}
$pdo->commit();
http_response_code(200);
echo 'OK';
} catch (Throwable $e) {
$pdo->rollBack();
http_response_code(400);
echo 'Payment verification failed';
}
支付平台可能重复发送同一个 Webhook,因此处理逻辑必须具备幂等性。相同的 gateway_payment_id 不能产生多个注册资格,状态已经是 paid 的记录也不能再次执行资格发放操作。
金额比较还要遵循支付平台的数据格式。有些平台使用最小货币单位,例如用整数 9900 表示 99.00。遇到这种情况,数据库也建议保存整数,以免产生浮点数误差。
在注册页面检查资格
<?php
declare(strict_types=1);
session_set_cookie_params([
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
$intentId = $_SESSION['registration_intent_id'] ?? null;
if (!is_string($intentId)) {
http_response_code(403);
exit('没有可用的注册资格');
}
$stmt = $pdo->prepare(
'SELECT status, expires_at, consumed_at
FROM registration_intents
WHERE id = :id'
);
$stmt->execute(['id' => $intentId]);
$intent = $stmt->fetch(PDO::FETCH_ASSOC);
$allowed =
$intent !== false &&
$intent['status'] === 'paid' &&
$intent['consumed_at'] === null &&
strtotime($intent['expires_at']) > time();
if (!$allowed) {
http_response_code(403);
exit('付款尚未确认、注册资格已过期或已经使用');
}
session_regenerate_id(true);
// 显示注册表单
提交注册表单时必须重新执行同样的检查,不能因为此前已经显示过表单就省略验证。表单还应加入 CSRF Token,防止其他网站冒用用户身份提交注册请求。
防止一笔付款重复注册
创建用户和消耗注册资格必须放在同一个数据库事务中:
<?php
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'SELECT status, expires_at, consumed_at
FROM registration_intents
WHERE id = :id
FOR UPDATE'
);
$stmt->execute(['id' => $intentId]);
$intent = $stmt->fetch(PDO::FETCH_ASSOC);
if (
!$intent ||
$intent['status'] !== 'paid' ||
$intent['consumed_at'] !== null ||
strtotime($intent['expires_at']) <= time()
) {
throw new RuntimeException('Registration is not allowed');
}
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare(
'INSERT INTO users (email, password_hash, created_at)
VALUES (:email, :password_hash, NOW())'
);
$stmt->execute([
'email' => $email,
'password_hash' => $passwordHash,
]);
$stmt = $pdo->prepare(
'UPDATE registration_intents
SET status = :status, consumed_at = NOW()
WHERE id = :id AND consumed_at IS NULL'
);
$stmt->execute([
'status' => 'consumed',
'id' => $intentId,
]);
if ($stmt->rowCount() !== 1) {
throw new RuntimeException('Registration intent was already consumed');
}
$pdo->commit();
unset($_SESSION['registration_intent_id']);
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
需要特别注意的安全问题
- 全站使用 HTTPS,并为 Session Cookie 设置
Secure、HttpOnly和合适的SameSite属性。 - 不要相信浏览器返回的
success=true、金额、订单状态或隐藏表单字段。 - 验证 Webhook 签名,同时核对订单号、支付状态、金额、币种和商户账户。
- 对 Webhook 进行幂等处理,避免重复回调产生多个注册资格。
- 提交注册信息时使用 CSRF Token,并再次查询数据库确认资格。
- 为注册资格设置有效期,使用成功后立即作废。
- 不要在 URL 中暴露可长期使用的注册凭据。如果需要支持跨设备注册,应使用有效期较短、只能使用一次的高强度随机令牌,数据库中只保存令牌的哈希值。
- Session ID 泄露后,注册资格仍可能被盗。因此,Session 不能取代数据库状态和一次性消费机制。
- 不要将密码、完整的支付卡信息或支付平台密钥写入 Session、日志或前端页面。
Session 适合记录“哪个浏览器发起了付款”,但“这笔钱是否真的到账”,必须以支付平台的服务端通知和数据库记录为准。
备注:内容仅供参考。
