PAYATHON 2026

Laravel 使用 Perfect Money 支付回调后 Session 丢失

支付小周

结论

Perfect Money 并没有删除 Laravel 的 Session。问题通常出在以下几个环节:

  1. Perfect Money 通过跨站 POST 请求访问 success/cancel URL。请求中没有 Laravel 的 CSRF Token,因此 Laravel 返回 419 Page Expired。
  2. 排除 CSRF 检查后,浏览器仍可能受 Session Cookie 的 SameSite=Lax 策略限制,不会在跨站 POST 中携带该 Cookie。Laravel 无法识别原来的登录会话,便会把用户视为未登录。
  3. Perfect Money 在 step1.asp 中保留的支付数据,属于它自己域名下的 Session 或 Cookie,与应用的 Laravel Session 相互独立。

处理时应将“支付通知”“浏览器返回”和“用户 Session”分开:支付结果以 STATUS_URL 的服务端通知为准;success/cancel 页面尽量使用顶层 GET 返回;所有支付回调都不能依赖用户 Session。

为什么会出现 419

Laravel 默认会对 web 中间件组中的 POST、PUT、PATCH 和 DELETE 请求执行 CSRF 校验。

Perfect Money 发来的请求无法携带 Laravel 页面生成的 _token。如果 success/cancel URL 接受的是 POST,就会触发:

419 | PAGE EXPIRED

将回调路由加入 CSRF 排除列表,只能解决第三方请求没有 CSRF Token 的问题,无法保证浏览器会同时携带 Session Cookie。

为什么排除 CSRF 后看起来 Session 丢失

Laravel 的 Session ID 通常保存在 Cookie 中,而现代浏览器会对 Cookie 应用 SameSite 限制。

假设支付流程如下:

your-app.example
    -> POST 到 perfectmoney.com
    -> Perfect Money 跨站 POST 回 your-app.example/payment/cancel

当 Laravel Session Cookie 使用常见的 SameSite=Lax 时,浏览器通常不会在跨站 POST 中发送该 Cookie。Laravel 收到 cancel 请求后,可能得到以下结果:

auth()->check(); // false
session()->get('order_id'); // null

这并不是 Perfect Money 删除了 Session,而是当前请求没有携带原来的 Session Cookie。有时 Laravel 还会为该请求创建一个新 Session,并在响应中写入新的 Session Cookie,于是看起来更像是“旧 Session 被清空”。

推荐的路由设计

至少应将 URL 分为三类:

use App\Http\Controllers\PerfectMoneyController;
use Illuminate\Support\Facades\Route;

// Perfect Money 服务器通知,不依赖登录状态和浏览器 Session
Route::post('/payments/perfect-money/status', [
    PerfectMoneyController::class,
    'status',
])->name('payments.perfect-money.status');

// 用户浏览器返回页面,优先配置成 GET
Route::get('/payments/perfect-money/success', [
    PerfectMoneyController::class,
    'success',
])->name('payments.perfect-money.success');

Route::get('/payments/perfect-money/cancel', [
    PerfectMoneyController::class,
    'cancel',
])->name('payments.perfect-money.cancel');

不要为这些返回路由添加 auth 中间件。浏览器没有携带 Session Cookie 时,auth 会立即将用户重定向到登录页面。

如果 Perfect Money 只能通过 POST 将浏览器送回应用,可以让路由同时接受 POST,但仍不能依赖 Session:

Route::match(['get', 'post'], '/payments/perfect-money/cancel', [
    PerfectMoneyController::class,
    'cancel',
])->name('payments.perfect-money.cancel');

PAYMENT_URL_METHOD 和 NOPAYMENT_URL_METHOD 的具体取值,应以当前 Perfect Money SCI 文档为准。如果网关支持链接式或 GET 返回,应优先选择这种方式,避免通过跨站 POST 恢复用户会话。

CSRF 应该如何排除

只有接收 Perfect Money POST 请求的路由需要排除 CSRF。不要因为支付回调问题而关闭整个站点的 CSRF 保护。

较早版本的 Laravel 项目可以在 VerifyCsrfToken.php 中配置:

protected $except = [
    'payments/perfect-money/status',
    'payments/perfect-money/success',
    'payments/perfect-money/cancel',
];

如果 success/cancel 已经改为只接受 GET,它们本身不需要 CSRF Token,此时只需排除通知地址:

protected $except = [
    'payments/perfect-money/status',
];

Laravel 11 及以后版本,如果项目采用新的应用配置方式,可以在 bootstrap/app.php 中设置:

use Illuminate\Foundation\Configuration\Middleware;

->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'payments/perfect-money/status',
    ]);
})

应使用哪种写法,取决于项目的 Laravel 版本和目录结构。

支付回调不要读取用户 Session

创建订单时,先将订单写入数据库,再把订单编号放入 Perfect Money 的 PAYMENT_ID,或放入官方支持的附加字段。

$order = Order::create([
    'user_id' => auth()->id(),
    'amount' => '49.90',
    'currency' => 'USD',
    'status' => 'pending',
]);

支付表单示意:

<form action="https://perfectmoney.com/api/step1.asp" method="POST">
    <input type="hidden" name="PAYEE_ACCOUNT" value="{{ config('services.perfect_money.account') }}">
    <input type="hidden" name="PAYEE_NAME" value="{{ config('app.name') }}">
    <input type="hidden" name="PAYMENT_ID" value="{{ $order->id }}">
    <input type="hidden" name="PAYMENT_AMOUNT" value="{{ $order->amount }}">
    <input type="hidden" name="PAYMENT_UNITS" value="{{ $order->currency }}">

    <input type="hidden" name="STATUS_URL" value="{{ route('payments.perfect-money.status') }}">
    <input type="hidden" name="PAYMENT_URL" value="{{ route('payments.perfect-money.success') }}">
    <input type="hidden" name="NOPAYMENT_URL" value="{{ route('payments.perfect-money.cancel') }}">

    <button type="submit">Pay with Perfect Money</button>
</form>

收到回调后,通过 PAYMENT_ID 查询订单:

public function status(Request $request)
{
    $order = Order::findOrFail($request->input('PAYMENT_ID'));

    // 验证 Perfect Money 签名、收款账号、金额和币种。
    // 验证通过后才能更新订单。
}

不要通过 Session 关联订单:

$orderId = session('current_order_id');

支付通知可能直接来自 Perfect Money 的服务器,并不经过用户浏览器,因此不会携带用户的 Session Cookie。

必须验证回调真实性

排除 CSRF 后,仍然不能直接信任回调。CSRF Token 用于验证本站的浏览器表单,不适合验证支付平台发来的请求。

Perfect Money SCI 常见的校验方式是根据回调字段和账户的 alternate passphrase 生成 V2_HASH,然后与请求中的 V2_HASH 比较。示意代码如下:

private function validPerfectMoneyHash(Request $request): bool
{
    $alternatePassphraseHash = strtoupper(
        md5(config('services.perfect_money.alternate_passphrase'))
    );

    $source = implode(':', [
        $request->input('PAYMENT_ID'),
        $request->input('PAYEE_ACCOUNT'),
        $request->input('PAYMENT_AMOUNT'),
        $request->input('PAYMENT_UNITS'),
        $request->input('PAYMENT_BATCH_NUM'),
        $request->input('PAYER_ACCOUNT'),
        $request->input('TIMESTAMPGMT'),
        $alternatePassphraseHash,
    ]);

    $expected = strtoupper(md5($source));
    $received = strtoupper((string) $request->input('V2_HASH'));

    return hash_equals($expected, $received);
}

字段顺序和签名规则必须与当前 Perfect Money SCI 文档核对,不能只根据浏览器提交的数据判断支付成功。签名验证通过后,还要检查:

if ($request->input('PAYEE_ACCOUNT') !== config('services.perfect_money.account')) {
    abort(400, 'Invalid payee account');
}

if ((string) $request->input('PAYMENT_AMOUNT') !== (string) $order->amount) {
    abort(400, 'Invalid payment amount');
}

if ($request->input('PAYMENT_UNITS') !== $order->currency) {
    abort(400, 'Invalid payment currency');
}

还需要保存并检查 PAYMENT_BATCH_NUM,避免重复处理同一个支付通知。订单状态更新也必须具备幂等性。

success 页面不能直接把订单标记为已支付

用户可以手动访问或伪造浏览器返回 URL,因此下面的处理方式并不安全:

public function success(Request $request)
{
    Order::whereKey($request->input('PAYMENT_ID'))
        ->update(['status' => 'paid']);
}

success 页面只应读取并展示数据库中的订单状态:

public function success(Request $request)
{
    $order = Order::findOrFail($request->input('PAYMENT_ID'));

    return view('payments.result', [
        'order' => $order,
    ]);
}

只有通过签名验证的 STATUS_URL 通知处理器,才能将订单状态更新为 paid。如果服务端通知可能晚于浏览器返回,success 页面可以显示“支付结果确认中”,并轮询应用自己的订单查询接口。

是否可以设置 SameSite=None

如果业务必须在跨站 POST 返回时恢复 Laravel Session,可以考虑以下配置:

// config/session.php
'secure' => true,
'same_site' => 'none',

有些项目也会通过环境变量配置:

SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=none

修改后需要清理配置缓存:

php artisan config:clear

不过,这并不是支付集成的首选方案,原因如下:

  • SameSite=None 必须与 HTTPS 和 Secure Cookie 配合使用。
  • 部分浏览器或隐私设置仍可能限制跨站 Cookie。
  • 全局放宽 Session Cookie 的跨站限制会扩大安全面。
  • 服务端支付通知始终无法获得用户浏览器中的 Session。

更可靠的做法是让返回页面使用 GET,通过数据库和订单编号定位订单,并根据经过签名验证的通知确认支付结果。

还需要检查域名和 HTTPS

如果改用 GET 后仍然无法恢复登录状态,应检查 success/cancel URL 是否更换了主机名或协议,例如:

https://example.com
https://www.example.com
http://example.com

这些地址对 Cookie 而言可能不属于同一作用域。需要重点检查:

APP_URL=https://example.com
SESSION_DOMAIN=example.com
SESSION_SECURE_COOKIE=true

支付表单中不要混用 www 和非 www 域名,也不要让返回地址从 HTTPS 变成 HTTP。修改 Session 配置后,应清除配置缓存并重新登录测试。

关于 Perfect Money 页面保留支付数据

访问 perfectmoney.com/api/step1.asp 时仍能看到此前提交的金额和收款账号,说明 Perfect Money 很可能在自己的域名下保存了临时流程状态。至于它使用服务器 Session 还是 Cookie,仅凭页面表现无法确定,这是 Perfect Money 自身的实现细节。

无论 Perfect Money 使用哪种机制,都无法直接清除应用域名下的 Laravel Cookie。浏览器的同源规则和 Cookie 域限制不允许 perfectmoney.com 修改 example.com 的 Session Cookie。

因此,这两个现象彼此独立:

  • Perfect Money 仍然显示原来的支付数据,说明它自己的流程状态还在。
  • Laravel 返回登录页面,说明跨站返回请求没有携带原来的 Laravel Session Cookie,或者返回地址使用了不同的域名或协议。

最终的集成方式应当是:由 STATUS_URL 独立验证通知并更新订单;success/cancel URL 只负责展示结果或导航;相关路由不依赖登录 Session;浏览器返回时优先使用 GET。

备注:内容仅供参考。