PAYATHON 2026

SecurePay 结账期间购物车被修改应如何处理?

支付小周

结论

支付完成后,不要重新读取购物车来生成订单。用户点击 “Pay now” 时,系统应把购物车内容复制为一份不可变的订单快照,计算并保存应付金额,再将订单号作为 reference number 发送给 SecurePay。

支付结果返回后,invoice 和库存扣减都必须基于这份订单快照。用户在站外支付期间对购物车所做的增删,应保留在购物车中,供下一笔订单使用,不能影响已经提交支付的订单。

SecurePay 不需要商品明细。支付网关只负责处理“某个 reference 支付了多少钱”,商品明细和库存则由商户自己的订单系统管理。

为什么不能把购物车当作订单

购物车是随时可能变化的临时数据,订单则记录某个时刻已经确认的交易。两者的生命周期不同:

  • 购物车中的商品可以继续添加、删除,也可以修改数量。
  • 订单提交后,商品、价格、运费、税费、折扣和总额都必须固定。
  • 每次支付请求只能对应一个明确的订单版本。
  • 支付成功后,系统必须核对订单号、支付状态、币种和金额。
  • invoice 和 stock take 必须依据订单明细,不能依据当前购物车。

所以,锁定购物车并不能真正解决问题。即使界面禁止修改,仍然要处理多标签页、重复请求、回调延迟、网络中断,以及用户直接构造请求等情况。

推荐的数据模型

至少要分别保存购物车、订单、订单明细和支付记录:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    public_reference VARCHAR(64) NOT NULL UNIQUE,
    customer_id BIGINT NULL,
    session_id VARCHAR(128) NULL,
    status VARCHAR(32) NOT NULL,
    currency CHAR(3) NOT NULL,
    subtotal_cents BIGINT NOT NULL,
    shipping_cents BIGINT NOT NULL,
    tax_cents BIGINT NOT NULL,
    discount_cents BIGINT NOT NULL,
    total_cents BIGINT NOT NULL,
    cart_version BIGINT NOT NULL,
    created_at TIMESTAMP NOT NULL,
    paid_at TIMESTAMP NULL
);

CREATE TABLE order_items (
    id BIGINT PRIMARY KEY,
    order_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    sku VARCHAR(100) NOT NULL,
    product_name VARCHAR(255) NOT NULL,
    quantity INT NOT NULL,
    unit_price_cents BIGINT NOT NULL,
    line_total_cents BIGINT NOT NULL,
    FOREIGN KEY (order_id) REFERENCES orders(id)
);

CREATE TABLE payment_attempts (
    id BIGINT PRIMARY KEY,
    order_id BIGINT NOT NULL,
    gateway VARCHAR(32) NOT NULL,
    gateway_reference VARCHAR(128) NULL,
    requested_amount_cents BIGINT NOT NULL,
    currency CHAR(3) NOT NULL,
    status VARCHAR(32) NOT NULL,
    created_at TIMESTAMP NOT NULL,
    completed_at TIMESTAMP NULL,
    FOREIGN KEY (order_id) REFERENCES orders(id)
);

金额最好按最小货币单位保存。例如,AUD 19.95 应保存为 1995,以避免浮点数计算误差。

order_items 需要保存下单时的商品名称、SKU 和单价快照。生成 invoice 时不能重新查询商品的当前价格,否则后台调价可能改变历史订单。

点击 “Pay now” 时的处理流程

后端应在一个数据库事务中完成以下操作:

  1. 读取当前购物车及其版本。
  2. 在服务器端重新验证价格、库存、折扣和运费。
  3. 创建状态为 pending_payment 的订单。
  4. 将购物车商品复制到 order_items。
  5. 保存订单总额和币种。
  6. 创建支付尝试记录。
  7. 提交事务。
  8. 将订单的 public_reference 和固定金额发送给 SecurePay。

伪代码如下:

function startCheckout(string $sessionId): PaymentRedirect
{
    return database()->transaction(function () use ($sessionId) {
        $cart = cartRepository()->getForUpdate($sessionId);

        if ($cart->items->isEmpty()) {
            throw new DomainException('Cart is empty');
        }

        // 所有金额必须由服务器重新计算,不能相信浏览器提交的金额。
        $pricing = pricingService()->calculate($cart);

        $order = orderRepository()->create([
            'public_reference' => generateUnpredictableReference(),
            'session_id'       => $sessionId,
            'status'           => 'pending_payment',
            'currency'         => 'AUD',
            'subtotal_cents'   => $pricing->subtotalCents,
            'shipping_cents'   => $pricing->shippingCents,
            'tax_cents'        => $pricing->taxCents,
            'discount_cents'   => $pricing->discountCents,
            'total_cents'      => $pricing->totalCents,
            'cart_version'     => $cart->version,
        ]);

        foreach ($pricing->items as $item) {
            orderItemRepository()->create([
                'order_id'         => $order->id,
                'product_id'       => $item->productId,
                'sku'              => $item->sku,
                'product_name'     => $item->name,
                'quantity'         => $item->quantity,
                'unit_price_cents' => $item->unitPriceCents,
                'line_total_cents' => $item->lineTotalCents,
            ]);
        }

        paymentRepository()->create([
            'order_id'               => $order->id,
            'gateway'                => 'securepay',
            'requested_amount_cents' => $order->totalCents,
            'currency'               => $order->currency,
            'status'                 => 'created',
        ]);

        return securePayClient()->buildRedirect([
            'reference' => $order->publicReference,
            'amount'    => formatGatewayAmount($order->totalCents),
            'currency'  => $order->currency,
        ]);
    });
}

这里的 buildRedirect() 和字段名称只是架构示例,并不代表 SecurePay 的实际 SDK 或接口参数。具体签名、金额格式和回调字段,应以当前使用的 SecurePay 产品文档为准。

支付完成后的处理

用户返回网站时,系统应通过 SecurePay 返回的 reference number 查找订单,而不是通过 cookie 查找购物车。

处理逻辑可以写成:

function completePayment(array $gatewayData): void
{
    verifyGatewayMessage($gatewayData);

    database()->transaction(function () use ($gatewayData) {
        $order = orderRepository()
            ->getByReferenceForUpdate($gatewayData['reference']);

        if ($order === null) {
            throw new DomainException('Unknown order reference');
        }

        // 回调可能被重复发送,必须保证幂等。
        if ($order->status === 'paid') {
            return;
        }

        $payment = paymentRepository()
            ->getCurrentAttemptForUpdate($order->id);

        if (!gatewayStatusIsSuccessful($gatewayData)) {
            paymentRepository()->markFailed($payment->id, $gatewayData);
            return;
        }

        $paidAmountCents = parseGatewayAmount($gatewayData['amount']);

        if ($paidAmountCents !== $payment->requestedAmountCents) {
            paymentRepository()->markForReview(
                $payment->id,
                'amount_mismatch',
                $gatewayData
            );

            orderRepository()->markPaymentReview($order->id);
            return;
        }

        if ($gatewayData['currency'] !== $payment->currency) {
            paymentRepository()->markForReview(
                $payment->id,
                'currency_mismatch',
                $gatewayData
            );

            orderRepository()->markPaymentReview($order->id);
            return;
        }

        paymentRepository()->markPaid($payment->id, $gatewayData);
        orderRepository()->markPaid($order->id);

        // 使用 order_items,不读取 cart_items。
        inventoryService()->commitForOrder($order->id);
        invoiceService()->issueForOrder($order->id);
    });
}

实际确认支付时,应优先使用 SecurePay 提供的、可以验证真实性的服务器端通知机制。如果所用产品不提供异步通知,就通过官方查询接口验证交易状态。不能仅凭浏览器跳转时带回的查询参数,或因为“成功页面已打开”,就将订单标记为已支付。

购物车在支付期间如何处理

通常有两种处理方式。

保留购物车并记录版本

创建订单时保存 cart_version。支付完成后,只有购物车版本仍与下单时一致,才能清空购物车:

if ($cart->version === $order->cartVersion) {
    cartRepository()->clear($cart->id);
}

如果用户在支付期间修改了购物车,版本也会随之变化,这时不能清空整个购物车。系统可以只移除数量仍与订单快照匹配的商品,但这种合并规则需要谨慎设计。

更简单的做法是,在订单创建后立即从购物车中移除已经结账的商品。用户继续购物时,新添加的商品自然成为下一笔购物车内容。

为后续购物创建新购物车

用户点击 “Pay now” 后,关闭当前购物车并将其关联到订单,同时为该用户或会话创建一个新购物车:

cart_1001 -> converted_to_order -> order_5001
cart_1002 -> active

用户仍然可以继续购买或接受 upsell,但后续商品会进入 cart_1002,不会影响正在支付的 order_5001。这种方式通常比长期维护一个 locked 状态更清楚。

如果 upsell 必须计入当前付款,就要先取消或废弃原支付尝试,重新计算订单,并按新金额发起支付。已经提交给网关的支付请求,金额不能在后台悄悄改变。

库存应何时扣减

扣减时机取决于商品是否容易售罄。

一般商品可以在支付成功后,根据 order_items 扣减库存。对于高并发或库存稀缺的商品,则应在创建订单时短期预留库存:

pending_payment -> reserved
paid            -> committed
expired/failed  -> released

库存预留要有明确的过期时间,并由后台任务释放超时订单。不能依赖用户返回网站后再释放库存,因为用户可能付款后直接关闭页面,也可能一直不返回。

无论采用库存预留还是支付后扣减,都要在数据库事务中检查库存,防止两个请求同时买到最后一件商品。

Cookie 中最好只保存随机且不可预测的会话标识,不能保存需要信任的金额、商品数量或订单状态。服务端还要处理以下事项:

  • Cookie 应使用 Secure、HttpOnly 和合适的 SameSite 设置。
  • 登录用户的订单应关联到 customer_id。
  • 访客订单可以关联会话,但支付回调仍要通过订单 reference 定位。
  • 公开订单 reference 应足够随机,避免连续订单号被猜中。
  • 查看订单详情时,仍需验证当前用户或会话是否拥有该订单。

支付网关回调属于系统间通信,不应依赖用户浏览器中的购物车 Cookie。

其他需要处理的情况

  • 不要等到支付成功页面打开后,才首次执行重要的结算逻辑。该页面可能被刷新、重复打开,也可能根本不会打开。
  • 支付通知可能重复、延迟或乱序,因此订单状态更新必须保持幂等。
  • 同一个订单可能有多次支付尝试,每次尝试都要单独记录。
  • 必须核对网关交易 reference、金额、币种和成功状态是否与本地支付记录一致。
  • 如果 SecurePay 另行收取 surcharge,需要先明确它是否包含在商户收到的支付金额中,再按照当前接口实际返回的字段核对,不能自行猜测。
  • 支付失败或取消后,可以将原订单保留为 payment_failed 或 cancelled,再按业务规则恢复为新购物车。
  • invoice 应引用订单快照。后续退款、取消或改单,应通过退款单、贷项单或订单调整记录处理,不能直接改写原始明细。

三类数据的职责应当分开:购物车记录“用户现在想买什么”,订单记录“用户提交支付时确认买了什么”,支付记录则记录“网关实际收到了多少钱”。只要三者相互独立,用户在站外支付期间修改购物车,就不会影响已经提交的订单。

备注:内容仅供参考。