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” 时的处理流程
后端应在一个数据库事务中完成以下操作:
- 读取当前购物车及其版本。
- 在服务器端重新验证价格、库存、折扣和运费。
- 创建状态为
pending_payment的订单。 - 将购物车商品复制到
order_items。 - 保存订单总额和币种。
- 创建支付尝试记录。
- 提交事务。
- 将订单的
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 中最好只保存随机且不可预测的会话标识,不能保存需要信任的金额、商品数量或订单状态。服务端还要处理以下事项:
- Cookie 应使用
Secure、HttpOnly和合适的SameSite设置。 - 登录用户的订单应关联到
customer_id。 - 访客订单可以关联会话,但支付回调仍要通过订单 reference 定位。
- 公开订单 reference 应足够随机,避免连续订单号被猜中。
- 查看订单详情时,仍需验证当前用户或会话是否拥有该订单。
支付网关回调属于系统间通信,不应依赖用户浏览器中的购物车 Cookie。
其他需要处理的情况
- 不要等到支付成功页面打开后,才首次执行重要的结算逻辑。该页面可能被刷新、重复打开,也可能根本不会打开。
- 支付通知可能重复、延迟或乱序,因此订单状态更新必须保持幂等。
- 同一个订单可能有多次支付尝试,每次尝试都要单独记录。
- 必须核对网关交易 reference、金额、币种和成功状态是否与本地支付记录一致。
- 如果 SecurePay 另行收取 surcharge,需要先明确它是否包含在商户收到的支付金额中,再按照当前接口实际返回的字段核对,不能自行猜测。
- 支付失败或取消后,可以将原订单保留为
payment_failed或cancelled,再按业务规则恢复为新购物车。 - invoice 应引用订单快照。后续退款、取消或改单,应通过退款单、贷项单或订单调整记录处理,不能直接改写原始明细。
三类数据的职责应当分开:购物车记录“用户现在想买什么”,订单记录“用户提交支付时确认买了什么”,支付记录则记录“网关实际收到了多少钱”。只要三者相互独立,用户在站外支付期间修改购物车,就不会影响已经提交的订单。
备注:内容仅供参考。
