如何在 checkout 时安全验证正确的付款金额?
结论
不要验证浏览器提交的 price 或 amount,也不要用它们结算。所有来自浏览器的数据都不可信。
从 buy.php 跳转到 payment.php 时,客户端只需提交:
offer_idquantitypayment_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,只能说明浏览器访问了这个地址,不能证明支付成功。攻击者完全可以直接打开该页面。
付款结果应以经过签名验证的服务端通知为准。如果支付网关没有可靠的通知机制,服务器就要主动调用网关的查询接口确认。
客户端负责表达“想买什么”,服务器负责确定“应该付多少钱”,支付网关负责证明“实际上付了多少钱”。这三部分必须各自承担对应的职责。
备注:内容仅供参考。
