PAYATHON 2026

如何在 checkout 时安全验证正确的付款金额?

支付小周

结论

不要验证浏览器提交的 price 或 amount,也不要用它们结算。所有来自浏览器的数据都不可信。

从 buy.php 跳转到 payment.php 时,客户端只需提交:

  • offer_id
  • quantity
  • payment_method
  • 必要的优惠码

payment.php 根据数据库中的商品价格、当前用户、购买数量和优惠规则,在服务端重新计算最终金额。计算完成后,创建一条不能随意修改的订单记录,再使用订单中保存的金额向支付网关发起请求。

支付完成后,还要通过支付网关的服务端通知或查询接口,核对订单号、金额、币种和支付状态。不能因为用户的浏览器跳回网站,就认定付款成功。

为什么不能相信 $_POST 中的金额

隐藏字段、只读输入框、JavaScript 计算结果和普通表单字段都可以修改。下面这个字段没有任何安全保证:

<input type="hidden" name="amount" value="199.00">

攻击者可以把它改成:

amount=0.01

即使表单通过 HTTPS 传输,也只是防止第三方在传输过程中窃听或篡改数据,无法阻止用户修改自己发送的请求。

因此,下面这些数据都不能作为订单金额的权威来源:

$_POST['price'];
$_POST['amount'];
$_POST['discount'];
$_POST['total'];

前端可以显示预估金额,但服务器必须独立计算。

推荐的结算流程

1. 浏览器只提交购买意图

例如:

offer_id=42
quantity=3
payment_method=card
coupon_code=SUMMER20

客户端不需要提交单价、折扣金额或应付总额。如果为了页面展示提交了这些字段,服务端也应直接忽略。

2. 服务端读取商品和优惠规则

payment.php 根据 offer_id 从数据库读取当前有效的商品信息,并检查:

  • 商品是否存在并允许购买
  • 当前销售价格
  • 最大和最小购买数量
  • 库存或购买次数限制
  • 支持的支付方式
  • 优惠活动是否有效
  • 当前用户是否符合优惠条件
  • 优惠是否可以叠加
  • 优惠使用次数是否超限

这些判断都必须以服务端数据为准。

3. 在服务端计算金额

金额可以按下面的方式计算:

subtotal = unit_price × quantity
discount = calculate_discount(subtotal, customer, offer, coupon)
total = subtotal - discount

付款金额最好用最小货币单位的整数表示。例如,人民币 19.90 元保存为 1990 分,这样可以避免浮点数精度问题:

$subtotalCents = $unitPriceCents * $quantity;
$discountCents = calculateDiscountCents(
    $subtotalCents,
    $userId,
    $offer,
    $couponCode
);
$totalCents = $subtotalCents - $discountCents;

不要使用 float 计算付款金额:

// 不推荐
$total = 19.90 * 3;

如果业务必须处理小数金额,应使用 BCMath 或专门的货币类型,同时明确币种的小数位规则。

4. 创建订单快照

计算完成后,把结算结果保存到订单中:

order_id
user_id
offer_id
quantity
unit_price_cents
subtotal_cents
discount_cents
total_cents
currency
payment_method
status
created_at

这些字段记录的是下单时的价格快照。以后无论商品涨价、降价,还是优惠活动结束,都不能改变这笔订单已经确定的金额。

向支付网关发起付款时,应从订单记录中读取 total_cents,不能再次读取客户端提交的金额。

PHP 实现示例

下面是一个简化示例。实际项目还要根据登录状态、库存、优惠次数、异常处理和具体支付网关进行调整。

<?php

declare(strict_types=1);

session_start();

require __DIR__ . '/db.php';

$userId = requireAuthenticatedUserId();

$offerId = filter_input(INPUT_POST, 'offer_id', FILTER_VALIDATE_INT);
$quantity = filter_input(INPUT_POST, 'quantity', FILTER_VALIDATE_INT);
$paymentMethod = $_POST['payment_method'] ?? '';
$couponCode = trim($_POST['coupon_code'] ?? '');

if ($offerId === false || $offerId === null) {
    throw new RuntimeException('Invalid offer.');
}

if ($quantity === false || $quantity === null || $quantity < 1) {
    throw new RuntimeException('Invalid quantity.');
}

$allowedPaymentMethods = ['card', 'paypal'];

if (!in_array($paymentMethod, $allowedPaymentMethods, true)) {
    throw new RuntimeException('Invalid payment method.');
}

$pdo->beginTransaction();

try {
    $stmt = $pdo->prepare(
        'SELECT id, price_cents, currency, active, max_quantity
         FROM offers
         WHERE id = :id
         FOR UPDATE'
    );
    $stmt->execute(['id' => $offerId]);

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

    if (!$offer || !(bool) $offer['active']) {
        throw new RuntimeException('Offer is not available.');
    }

    if ($quantity > (int) $offer['max_quantity']) {
        throw new RuntimeException('Requested quantity is not allowed.');
    }

    $unitPriceCents = (int) $offer['price_cents'];
    $subtotalCents = $unitPriceCents * $quantity;

    $discountCents = calculateDiscountCents(
        pdo: $pdo,
        userId: $userId,
        offerId: $offerId,
        quantity: $quantity,
        subtotalCents: $subtotalCents,
        couponCode: $couponCode
    );

    if ($discountCents < 0 || $discountCents > $subtotalCents) {
        throw new RuntimeException('Invalid discount result.');
    }

    $totalCents = $subtotalCents - $discountCents;

    if ($totalCents <= 0) {
        throw new RuntimeException('Invalid payment amount.');
    }

    $orderId = bin2hex(random_bytes(16));

    $stmt = $pdo->prepare(
        'INSERT INTO orders (
            id,
            user_id,
            offer_id,
            quantity,
            unit_price_cents,
            subtotal_cents,
            discount_cents,
            total_cents,
            currency,
            payment_method,
            status,
            created_at
        ) VALUES (
            :id,
            :user_id,
            :offer_id,
            :quantity,
            :unit_price_cents,
            :subtotal_cents,
            :discount_cents,
            :total_cents,
            :currency,
            :payment_method,
            :status,
            NOW()
        )'
    );

    $stmt->execute([
        'id' => $orderId,
        'user_id' => $userId,
        'offer_id' => $offerId,
        'quantity' => $quantity,
        'unit_price_cents' => $unitPriceCents,
        'subtotal_cents' => $subtotalCents,
        'discount_cents' => $discountCents,
        'total_cents' => $totalCents,
        'currency' => $offer['currency'],
        'payment_method' => $paymentMethod,
        'status' => 'pending',
    ]);

    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

/*
 * 这里应从数据库订单记录中取得金额,并通过支付网关的服务端 SDK
 * 或 API 创建支付请求。不要从 $_POST 读取 amount。
 */
$paymentUrl = createGatewayPayment([
    'merchant_order_id' => $orderId,
    'amount_cents' => $totalCents,
    'currency' => $offer['currency'],
    'payment_method' => $paymentMethod,
]);

header('Location: ' . $paymentUrl, true, 303);
exit;

优惠计算函数也只能读取可信数据:

function calculateDiscountCents(
    PDO $pdo,
    int $userId,
    int $offerId,
    int $quantity,
    int $subtotalCents,
    string $couponCode
): int {
    if ($couponCode === '') {
        return 0;
    }

    $stmt = $pdo->prepare(
        'SELECT id, discount_percent, starts_at, ends_at, usage_limit
         FROM coupons
         WHERE code = :code
           AND active = 1'
    );
    $stmt->execute(['code' => $couponCode]);

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

    if (!$coupon) {
        throw new RuntimeException('Coupon is invalid.');
    }

    // 这里继续检查有效期、适用商品、用户资格、使用次数及叠加规则。

    $percent = (int) $coupon['discount_percent'];

    if ($percent < 0 || $percent > 100) {
        throw new RuntimeException('Coupon configuration is invalid.');
    }

    return intdiv($subtotalCents * $percent, 100);
}

百分比优惠涉及舍入时,要提前确定统一规则,例如向下取整、四舍五入,或按照支付网关的规定处理。舍入规则应集中在服务端实现,不能交给前端决定。

支付回调还要再次核对

订单创建无误,不等于支付已经成功。收到支付网关的服务端通知后,至少要验证:

  • 回调签名是否合法
  • 网关商户号是否属于自己
  • 商户订单号是否存在
  • 网关交易号是否已经处理过
  • 实付金额是否等于订单金额
  • 币种是否一致
  • 支付状态是否确实成功
  • 订单当前是否仍可支付

示意代码如下:

$event = verifyGatewayWebhook($rawBody, $signatureHeader);

$order = findOrderForUpdate($event['merchant_order_id']);

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

if ($event['merchant_id'] !== EXPECTED_MERCHANT_ID) {
    throw new RuntimeException('Merchant mismatch.');
}

if ((int) $event['amount_cents'] !== (int) $order['total_cents']) {
    throw new RuntimeException('Amount mismatch.');
}

if ($event['currency'] !== $order['currency']) {
    throw new RuntimeException('Currency mismatch.');
}

if ($event['payment_status'] !== 'succeeded') {
    throw new RuntimeException('Payment is not complete.');
}

if ($order['status'] === 'paid') {
    // 重复通知,直接返回成功,避免重复发货。
    acknowledgeWebhook();
    exit;
}

markOrderAsPaid(
    orderId: $order['id'],
    gatewayTransactionId: $event['transaction_id']
);

具体字段名、签名方式和支付状态取决于所使用的支付网关,应以该网关当前的官方文档为准。

还需要注意的安全问题

防止订单重复创建

用户可能双击付款按钮、刷新页面,也可能因为网络重试而重复提交。可以为结算请求设置一次性 token,或者使用支付网关支持的 idempotency key。

订单状态更新和优惠次数扣减也要具备幂等性,避免同一个支付通知触发重复发货。

使用数据库事务

库存占用、优惠额度使用和订单创建之间可能发生并发冲突。如果只是“先查询,再写入”,多个请求可能同时用掉最后一个优惠名额。

必要时,应使用数据库事务、条件更新、唯一约束或行锁。锁定范围需要根据实际数据模型设计,不能只依赖页面层面的检查。

不要把 Session 当作价格数据库

Session 可以保存当前选择、CSRF token 或临时订单号,但商品价格和优惠规则仍要从服务器端的权威数据源读取。Session 内容可能过期,也不能替代正式的订单记录。

添加 CSRF 防护

CSRF token 可以防止其他网站冒用已登录用户发起结算请求,但不能证明金额正确。金额是否安全,仍取决于服务端是否独立计算。

不要仅信任支付成功跳转页

用户进入 success.php,只能说明浏览器访问了这个地址,不能证明支付成功。攻击者完全可以直接打开该页面。

付款结果应以经过签名验证的服务端通知为准。如果支付网关没有可靠的通知机制,服务器就要主动调用网关的查询接口确认。

客户端负责表达“想买什么”,服务器负责确定“应该付多少钱”,支付网关负责证明“实际上付了多少钱”。这三部分必须各自承担对应的职责。

备注:内容仅供参考。