PAYATHON 2026

创建订单与 checkout 系统,防止支付期间购物车被修改

支付阿杰

结论

不要等支付完成后再根据 session 创建订单。用户进入第三方支付页面前,服务端就应该在数据库中生成一份不可变的订单快照,并使用快照中的金额发起支付。

建议按以下流程处理:

  1. 用户点击“去支付”。
  2. 服务端重新读取商品价格、优惠、运费和库存,计算最终金额。
  3. 在正式订单表中创建状态为 pending_payment 的订单和订单明细。
  4. 将内部订单号、金额、币种等信息提交给支付平台。
  5. 根据支付平台的服务端回调确认付款结果。
  6. 校验回调中的订单号、金额、币种和支付状态。
  7. 原子地将订单状态更新为 paid。
  8. 将长时间未支付的订单改为 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

处理回调时,至少需要完成以下检查:

  1. 验证支付平台的签名或消息认证码。
  2. 必要时通过支付平台 API 再次查询支付状态。
  3. 使用商户订单号查找本地订单。
  4. 校验支付金额与 orders.total_amount 完全一致。
  5. 校验币种和商户账号。
  6. 检查支付交易号是否已经处理。
  7. 在事务中将订单状态从 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。

备注:内容仅供参考。