PAYATHON 2026

PHP 中付款成功后如何安全进入注册流程?

支付阿杰

结论

PHP Session 可以保存当前浏览器的注册状态,但不能单独用来证明用户已经付款。

付款应由支付平台的服务端回调(Webhook)确认,并将结果写入数据库。注册页面每次都要查询数据库,确认订单已付款、金额正确、资格尚未使用。Session 只需保存一个无法猜测的注册流程 ID,用于关联当前浏览器与订单。

不要根据付款后的跳转 URL、$_GET['success']、前端 JavaScript,或 Session 中单独保存的布尔值开放注册,因为这些信息都可能被伪造或劫持。

推荐流程

可以按以下流程实现:

  1. 用户付款前,服务端创建一条状态为 pending 的注册意向记录。
  2. 将该记录的随机 ID 保存到 Session,并作为订单号或 metadata 传给支付平台。
  3. 支付平台扣款成功后,通过 Webhook 通知服务器。
  4. 服务器验证回调签名,并核对支付状态、订单号、金额、币种和商户信息。
  5. 验证通过后,将数据库中的状态更新为 paid。
  6. 用户返回网站时,signup.php 根据 Session 中的 ID 查询数据库。
  7. 只有记录状态为 paid、尚未使用且未过期,才允许显示或提交注册表单。
  8. 注册成功后,立即将付款记录标记为 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 适合记录“哪个浏览器发起了付款”,但“这笔钱是否真的到账”,必须以支付平台的服务端通知和数据库记录为准。

备注:内容仅供参考。