创建订单与 checkout 系统,防止支付期间购物车被修改
结论
不要等支付完成后再根据 session 创建订单。用户进入第三方支付页面前,服务端就应该在数据库中生成一份不可变的订单快照,并使用快照中的金额发起支付。
建议按以下流程处理:
- 用户点击“去支付”。
- 服务端重新读取商品价格、优惠、运费和库存,计算最终金额。
- 在正式订单表中创建状态为
pending_payment的订单和订单明细。 - 将内部订单号、金额、币种等信息提交给支付平台。
- 根据支付平台的服务端回调确认付款结果。
- 校验回调中的订单号、金额、币种和支付状态。
- 原子地将订单状态更新为
paid。 - 将长时间未支付的订单改为
expired或cancelled,通常不必物理删除。
支付过程中,购物车仍然可以发生变化,但这些变化只能影响下一次结算,不能修改已经创建的待支付订单。
为什么不能继续依赖 Session
session 适合保存用户当前的购物车状态,但不适合作为待支付订单的最终依据。
创建支付请求时,需要固定以下内容:
- 商品 ID、SKU、名称及购买数量
- 商品成交单价
- 优惠与折扣
- 税费和运费
- 币种
- 应付总额
- 收货信息
- 订单号
- 支付状态
这些数据应作为订单快照存入数据库。此后即使商品改价、购物车内容变化、优惠过期,或者用户同时打开多个标签页,也不能影响已经提交支付的订单金额。
金额必须由服务端计算,不能相信浏览器提交的 total。如果支付金额由客户端决定,用户可以直接修改表单或请求内容。
不需要单独设计 temp_order 表
通常可以直接使用正式订单表,通过状态区分订单所处的阶段:
pending_payment -> paid
pending_payment -> expired
pending_payment -> cancelled
paid -> refunded
这样处理有几个好处:
- 订单 ID 始终来自同一张表,不会出现临时表与永久表的 ID 冲突。
- 支付完成后不需要复制或移动订单明细。
- 支付失败或用户放弃支付时仍有记录,便于排查、统计和重新支付。
- 与跨表迁移相比,状态转换更容易放在数据库事务中完成。
简化后的数据结构可以写成:
CREATE TABLE orders (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
order_no VARCHAR(64) NOT NULL UNIQUE,
user_id BIGINT NULL,
status VARCHAR(32) NOT NULL,
currency CHAR(3) NOT NULL,
subtotal DECIMAL(18, 2) NOT NULL,
discount_amount DECIMAL(18, 2) NOT NULL DEFAULT 0,
shipping_amount DECIMAL(18, 2) NOT NULL DEFAULT 0,
tax_amount DECIMAL(18, 2) NOT NULL DEFAULT 0,
total_amount DECIMAL(18, 2) NOT NULL,
payment_reference VARCHAR(128) NULL,
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NULL,
paid_at TIMESTAMP NULL
);
CREATE TABLE order_items (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
sku VARCHAR(128) NOT NULL,
product_name VARCHAR(255) NOT NULL,
quantity INT NOT NULL,
unit_price DECIMAL(18, 2) NOT NULL,
line_total DECIMAL(18, 2) NOT NULL,
FOREIGN KEY (order_id) REFERENCES orders(id)
);
生产环境中,可能还需要地址、优惠、税费明细、支付尝试和状态历史等表。
如果业务确实需要保留 temp_order,也应该使用独立的全局订单号,例如 UUID、ULID 或数据库序列,而不是分别依赖两张表的自增主键。但如果拆表只是为了区分“待支付”和“已支付”,通常没有必要。
应在哪个环节保存订单
处理“去支付”按钮的服务端接口,正是保存订单并发起支付的地方。该接口可以先写入数据库,提交事务后返回一个自动提交到支付平台的 HTML 表单,也可以调用支付平台 API 获取跳转地址。
示例流程如下:
<?php
session_start();
$db->beginTransaction();
try {
// 购物车只提供商品和数量等输入。
$cart = $_SESSION['cart'] ?? [];
if (!$cart) {
throw new RuntimeException('购物车为空');
}
// 必须从数据库重新获取价格、库存和商品状态。
$pricedItems = priceCartOnServer($db, $cart);
$subtotal = calculateSubtotal($pricedItems);
$discount = calculateDiscount($db, $pricedItems, $_SESSION);
$shipping = calculateShipping($pricedItems, $_SESSION);
$tax = calculateTax($pricedItems, $_SESSION);
$total = $subtotal - $discount + $shipping + $tax;
$orderNo = generateOrderNumber();
$orderId = insertOrder($db, [
'order_no' => $orderNo,
'status' => 'pending_payment',
'currency' => 'CNY',
'subtotal' => $subtotal,
'discount_amount' => $discount,
'shipping_amount' => $shipping,
'tax_amount' => $tax,
'total_amount' => $total,
'expires_at' => new DateTimeImmutable('+30 minutes'),
]);
foreach ($pricedItems as $item) {
insertOrderItem($db, $orderId, [
'product_id' => $item['product_id'],
'sku' => $item['sku'],
'product_name' => $item['name'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
'line_total' => $item['unit_price'] * $item['quantity'],
]);
}
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
?>
<form id="payment-form" method="post" action="https://payment.example/checkout">
<input type="hidden" name="merchant_order_id"
value="<?= htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="amount"
value="<?= htmlspecialchars(formatAmount($total), ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="currency" value="CNY">
<!-- 按支付平台要求加入签名、回调地址等字段 -->
</form>
<script>
document.getElementById('payment-form').submit();
</script>
处理顺序如下:
服务端验证购物车
↓
计算最终金额
↓
事务内创建订单和订单明细
↓
提交数据库事务
↓
使用该订单的编号和金额发起第三方支付
不能先跳转到支付平台,再试图从已经发生变化的 session 中恢复当时的订单。
支付结果不能只相信浏览器重定向
用户支付后进入收据页,并不能证明付款已经成功。返回地址可以被用户直接访问,也可能因为网络中断而根本没有被访问。
应优先使用支付平台提供的服务端通知机制,常见名称包括:
- webhook
- callback
- notify URL
- IPN
处理回调时,至少需要完成以下检查:
- 验证支付平台的签名或消息认证码。
- 必要时通过支付平台 API 再次查询支付状态。
- 使用商户订单号查找本地订单。
- 校验支付金额与
orders.total_amount完全一致。 - 校验币种和商户账号。
- 检查支付交易号是否已经处理。
- 在事务中将订单状态从
pending_payment改为paid。
伪代码示例:
<?php
$payload = readRawRequestBody();
if (!verifyProviderSignature($payload, $_SERVER)) {
http_response_code(400);
exit('invalid signature');
}
$event = parsePaymentEvent($payload);
$db->beginTransaction();
try {
$order = findOrderForUpdate($db, $event['merchant_order_id']);
if (!$order) {
throw new RuntimeException('订单不存在');
}
if ($order['currency'] !== $event['currency']) {
throw new RuntimeException('支付币种不一致');
}
if (!amountsEqual($order['total_amount'], $event['amount'])) {
throw new RuntimeException('支付金额不一致');
}
if ($order['status'] === 'paid') {
// 支付平台可能重复发送回调,已完成时直接按成功处理。
$db->commit();
exit('success');
}
if ($order['status'] !== 'pending_payment') {
throw new RuntimeException('当前订单状态不允许确认付款');
}
markOrderPaid(
$db,
$order['id'],
$event['provider_transaction_id']
);
$db->commit();
echo 'success';
} catch (Throwable $e) {
$db->rollBack();
http_response_code(409);
echo 'failed';
}
具体的签名算法、应答内容和重试规则取决于支付平台,应以该平台的接口文档为准。
金额应避免浮点数
货币计算不要使用二进制浮点数,例如直接用 float 计算 0.1 + 0.2。可以采用以下方式:
- 使用整数表示最小货币单位,例如人民币的“分”。
- 使用数据库
DECIMAL,并配合可靠的十进制定点计算库。
以“分”为单位时:
$unitPriceFen = 1299;
$quantity = 3;
$lineTotalFen = $unitPriceFen * $quantity; // 3897
向支付平台提交金额时,要严格遵守其接口对金额单位和格式的要求。有的平台接受分,有的平台接受带两位小数的十进制字符串。
处理重复点击和并发请求
用户可能双击支付按钮、刷新页面,或者从多个标签页同时发起结算。因此,创建订单和处理回调都要具备幂等性。
常用措施包括:
- 为每次结算生成
checkout_token,并添加唯一索引。 - 同一个 token 重复请求时,返回已经创建的订单。
- 为支付平台交易号添加唯一索引。
- 更新订单状态时加入旧状态条件。
- 在处理回调的事务中使用行锁或条件更新。
例如:
UPDATE orders
SET status = 'paid',
payment_reference = :payment_reference,
paid_at = CURRENT_TIMESTAMP
WHERE id = :order_id
AND status = 'pending_payment';
只有受影响行数为 1 时,当前请求才真正完成了状态转换。其他重复回调可以安全地返回成功,防止支付平台持续重试。
支付记录最好单独保存:
CREATE TABLE payment_transactions (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
order_id BIGINT NOT NULL,
provider VARCHAR(64) NOT NULL,
provider_transaction_id VARCHAR(128) NOT NULL,
amount DECIMAL(18, 2) NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE (provider, provider_transaction_id),
FOREIGN KEY (order_id) REFERENCES orders(id)
);
未完成订单如何清理
待支付订单不一定要删除。更稳妥的处理方式是定期将过期记录改为 expired:
UPDATE orders
SET status = 'expired'
WHERE status = 'pending_payment'
AND expires_at < CURRENT_TIMESTAMP;
这样既能保留审计记录,也便于分析结算流失情况。如果历史数据过多,可以根据业务和合规要求,归档或删除较早的过期订单。
还需要处理一种边界情况:订单刚刚过期,支付平台随后却通知付款成功。具体处理方式取决于业务履约能力:
- 库存和价格仍能履约时,允许订单从
expired转为paid。 - 已经无法履约时,记录异常支付,并自动退款或转交人工处理。
- 如果支付平台支持设置支付有效期,创建支付请求时应将其设为与订单相同的过期时间,尽量减少这种情况。
不要只依靠本地定时任务,把“没有收到回调”等同于“没有付款”。在取消库存、退款或转交人工处理前,必要时应主动向支付平台查询最终状态。
库存与购物车的处理
是否在创建待支付订单时扣减库存,需要根据业务情况决定:
- 普通商品可以在创建订单时预留库存,订单过期后再释放。
- 库存充足时,也可以等付款成功后再扣减,但要处理支付成功后无货的情况。
- 高并发或稀缺商品通常需要预留库存,并设置明确的过期时间。
购物车可以在创建订单后清空,也可以等付款成功后再清空。无论选择哪种方式,都不能影响订单快照。如果允许用户继续购物,就应把购物车当作一份独立且可变的数据。
推荐的完整状态流
用户提交结算
↓
服务端重新定价并验证库存
↓
创建 pending_payment 订单及明细
↓
向支付平台提交订单号、金额、币种和签名
↓
用户在第三方页面付款
↓
支付平台服务端回调
↓
验证签名、订单号、金额、币种和交易号
↓
事务内幂等更新为 paid
↓
收据页从数据库读取订单,不再读取购物车 Session
整个流程应遵守同一条原则:支付请求、支付确认和收据展示都要基于同一份数据库订单快照,不能依赖随时可能变化的购物车 session。
备注:内容仅供参考。
