外部支付 POST 导致 session 重置后如何恢复 Cart?
结论
不要把 Session Cart 当作支付流程中的可靠数据源。跳转到外部支付页面前,应先将购物车快照保存到订单和订单明细表中。支付回调只更新订单状态,不操作当前请求中的 Cart。用户返回站点后,再根据订单状态清空原购物车,或从订单明细中恢复购物车。
外部支付平台发起的跨站 POST 通常不会携带原来的 Session Cookie,Cookie 使用 SameSite=Lax 时尤其如此。Laravel 随后会创建一个新 Session。表面上看是“Session 被重置”,实际情况往往是回调请求无法访问浏览器原来的 Session。
为什么 Cart 会消失
mindscms/laravelshoppingcart 通常将购物车保存在 Session 中。当前请求能否识别原 Session,直接决定了购物车是否存在。
常见过程如下:
- 用户在商城域名下创建购物车,Cart 写入 Session A。
- 用户跳转到外部支付域名。
- 支付平台通过跨站
POST返回商城。 - 浏览器可能不会发送商城的 Session Cookie。
- Laravel 为该请求创建 Session B。
- 如果后续重定向继续使用 Session B,应用看到的就是空 Cart。
因此,问题未必出在某处调用了 Cart::destroy(),也可能是当前请求已经脱离原来的 Session。
如果支付平台调用的是服务器到服务器的通知接口,请求本身就不属于用户浏览器,更不应该依赖用户 Session。
推荐的支付流程
支付流程可以分成三个部分:
- 创建订单:根据 Cart 创建订单和订单明细,保存不可变的结算快照。
- 支付通知:支付平台调用无状态回调接口,应用验证通知并更新订单。
- 浏览器返回:用户通过站内返回地址查看支付结果。支付成功时清空对应购物车,失败时根据订单快照恢复。
订单明细至少需要保存:
- 商品 ID 或 SKU
- 商品名称
- 购买数量
- 结算单价
- 税费、折扣等金额
- 商品选项
- 订单总额
- 支付状态
- 随机且不可猜测的支付引用值
支付回调时不要根据商品表重新计算历史订单金额。支付过程中,商品价格、库存或促销规则都可能发生变化。
创建订单时保存购物车快照
以下代码只展示流程,字段名称需要按实际数据表调整。
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
public function createPayment()
{
$order = DB::transaction(function () {
$cartItems = Cart::content();
if ($cartItems->isEmpty()) {
abort(422, 'Cart is empty.');
}
$order = Order::create([
'user_id' => auth()->id(),
'payment_reference' => (string) Str::uuid(),
'status' => 'pending',
'currency' => 'BGN',
'amount' => Cart::total(),
]);
foreach ($cartItems as $item) {
$order->items()->create([
'product_id' => $item->id,
'name' => $item->name,
'quantity' => $item->qty,
'unit_price' => $item->price,
'options' => $item->options->toArray(),
]);
}
return $order;
});
// 将 $order->payment_reference、金额和回调地址提交给支付平台
return $this->redirectToPaymentProvider($order);
}
金额字段应使用数据库的定点小数,或采用最小货币单位,例如整数分,以免出现浮点数精度问题。提交给支付平台的金额必须来自已经保存的订单,不能在之后重新读取 Cart。
如果业务允许用户继续修改购物车,还可以额外保存购物车标识:
$order->update([
'cart_session_id' => session()->getId(),
]);
Session ID 只能用于辅助关联,不能作为支付凭证或公开参数。
将支付通知设计成无状态接口
支付通知接口不应读取或销毁 Cart,也不应依赖 session()、auth() 或 Flash Message。
public function callback(Request $request)
{
$payload = $request->all();
if (! $this->verifyResponse($request)) {
return response('Invalid signature', 400);
}
$reference = $request->input('ORDER');
$order = Order::where('payment_reference', $reference)->firstOrFail();
// 必须核对订单号、金额、币种和商户标识。
if (! $this->matchesOrder($request, $order)) {
return response('Payment data mismatch', 422);
}
DB::transaction(function () use ($order, $payload) {
$lockedOrder = Order::whereKey($order->getKey())
->lockForUpdate()
->firstOrFail();
// 支付平台可能重复发送通知,因此处理必须具备幂等性。
if ($lockedOrder->status === 'paid') {
return;
}
$lockedOrder->update([
'status' => 'paid',
'amount_paid' => $lockedOrder->amount,
'paid_at' => now(),
'payment_payload' => $payload,
]);
$this->orderService->finish($lockedOrder);
});
return response('OK');
}
不能直接信任通知中的 ORDER、AMOUNT 和支付成功标志。至少需要完成以下检查:
- 验证支付平台的签名或 MAC。
- 根据本地订单取得预期金额和币种。
- 比较商户号、订单号、金额和币种。
- 防止同一通知触发重复发货或重复记账。
- 保存支付平台交易号,并在需要时建立唯一索引。
- 对支付结果有疑问时,通过支付平台的服务端查询接口复核。
邮件最好放入队列,并在数据库事务成功提交后发送,以免回调重试造成重复发送。
单独提供浏览器返回地址
如果支付平台可以分别配置通知地址和用户返回地址,应使用两个不同的路由:
Route::post('/payments/provider/callback', [PaymentController::class, 'callback'])
->name('payments.callback');
Route::get('/payments/provider/return/{reference}', [PaymentController::class, 'return'])
->name('payments.return');
返回地址只负责展示订单结果:
public function return(string $reference)
{
$order = Order::where('payment_reference', $reference)->firstOrFail();
if ($order->status === 'paid') {
Cart::destroy();
return redirect()
->route('checkout.success', ['order' => $order->number]);
}
$this->restoreCartFromOrder($order);
return redirect()
->route('checkout.index')
->withErrors(['支付未完成,请重试。']);
}
公开返回地址中的引用值必须足够随机,也可以使用 Laravel 的签名 URL。即使使用签名 URL,仍须检查当前用户是否有权访问该订单。
支付失败后恢复 Cart
从订单明细恢复 Cart,通常比维护单独的临时 Cart 表更简单,因为订单明细本身就是结算凭证:
private function restoreCartFromOrder(Order $order): void
{
if (! Cart::content()->isEmpty()) {
return;
}
$order->loadMissing('items');
foreach ($order->items as $item) {
Cart::add(
$item->product_id,
$item->name,
$item->quantity,
$item->unit_price,
$item->options ?? []
);
}
}
上述 Cart::add() 的参数形式应以项目安装的 mindscms/laravelshoppingcart 实际 API 为准。如果当前版本提供 store()、restore() 等数据库持久化能力,也可以使用,但订单明细仍需保留,因为订单数据不能依赖可变的 Cart。
恢复前需要确定具体的业务策略:
- 如果当前 Cart 已有新商品,不应直接覆盖。
- 如果原商品已下架或库存不足,需要提示用户。
- 是否沿用原订单价格,应按业务规则决定。
- 用户重复返回失败订单时,恢复操作不能重复添加商品。
可以为订单增加 cart_restored_at,也可以在恢复时比较商品和数量,保证恢复操作具有幂等性。
SameSite 设置能否解决
Session Cookie 可以调整,但不能将其作为唯一的解决方案。
Laravel 的相关配置通常位于 config/session.php:
'same_site' => 'none',
'secure' => true,
对应的环境配置可能是:
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=none
可用的环境变量名称取决于项目当前的 config/session.php,应以该文件为准。
使用 SameSite=None 时,Cookie 必须同时设置 Secure,站点也必须使用 HTTPS。即便满足这些条件,浏览器隐私策略、内嵌支付页面、WebView 或第三方 Cookie 限制仍可能阻止 Cookie,无法保证 Session 始终连续。
如果支付平台可以通过顶层 GET 导航将用户送回站点,通常更适合保留 SameSite=Lax。服务端支付通知则始终应按无状态请求处理。
CSRF 与外部 POST 回调
外部支付平台无法携带 Laravel 的 CSRF Token,因此回调路由通常需要排除 CSRF 校验,或放入合适的 API 路由中。Laravel 10 项目可以在 VerifyCsrfToken 中排除精确路径:
protected $except = [
'payments/provider/callback',
];
排除 CSRF 不代表取消安全验证。该端点必须使用支付平台提供的签名、证书、MAC 或其他协议级验证,不能只依赖来源 IP 或请求中的成功字段。
对现有代码的调整
现有代码最需要调整的是,不要在支付平台的回调请求中调用 Cart::destroy()。该调用操作的是回调请求当前绑定的 Session,而这个 Session 未必属于发起付款的用户。
可以改为:
if ($validPayment) {
$this->markOrderAsPaid($order, $request);
return response('OK');
}
$this->markPaymentAttemptAsFailed($order, $request);
return response('OK');
之后,当浏览器进入返回页面或用户再次打开结账页面时,再根据订单状态处理原 Cart。订单支付成功就清空 Cart,失败或取消则恢复。
以下写法也存在歧义:
$orderId = ltrim($request->ORDER, '0');
如果订单号允许前导零,或者整个值都由零组成,裁剪后可能得到错误标识。更稳妥的做法是保存并使用独立的随机 payment_reference,不要通过字符串裁剪推导数据库主键。
Session Cart 适合保存浏览过程中的临时状态。跨域支付流程应以订单和支付记录作为持久化事实来源。这样,即使 Cookie 丢失、用户更换设备、回调被重复发送或浏览器没有返回,系统仍能可靠保存订单状态,并恢复购物车内容。
备注:内容仅供参考。
