Java 跳转 Payment Gateway 后 session 数据丢失
结论
Payment Gateway 返回后,如果 session.getAttribute("id") 是 null,通常不是这个属性被单独删除了,而是返回请求没有携带原来的 JSESSIONID,服务器创建了新的 session。其他可能原因包括原 session 已过期、服务器重启,或者请求被负载均衡到没有共享 session 的节点。
支付流程不应依赖 HTTP session 保存关键状态。订单号、用户标识和支付状态都应持久化到数据库,再通过支付网关提供的商户订单号或回调参数重新关联。session 只适合作为辅助信息。
先确认是否还是同一个 session
分别记录保存和读取属性时的 session ID:
HttpSession session = request.getSession();
session.setAttribute("id", 120);
System.out.println("Before payment: " + session.getId());
返回接口中先不要创建新 session,而是检查原 session 是否存在:
HttpSession session = request.getSession(false);
if (session == null) {
System.out.println("No existing session");
} else {
System.out.println("After payment: " + session.getId());
Integer id = (Integer) session.getAttribute("id");
System.out.println("id = " + id);
}
如果两次 session.getId() 不同,或者 request.getSession(false) 返回 null,说明问题出在 session 的关联或生命周期上,与 setAttribute() 无关。
常见原因
1. JSESSIONID Cookie 没有随返回请求发送
浏览器通常使用 JSESSIONID Cookie 识别 session。跳转到第三方支付域名再返回时,以下情况可能导致浏览器不发送该 Cookie:
- 返回地址的域名与创建 session 时不同,例如
www.example.com和pay.example.com - HTTP 与 HTTPS 混用
- Cookie 的
Path没有覆盖返回接口 SameSite策略阻止跨站请求携带 Cookie- 浏览器禁用了 Cookie
尤其要注意支付网关通过表单 POST 跳回网站的情况。某些 SameSite=Lax 配置不会在跨站 POST 请求中发送 session Cookie。
如果业务确实需要在跨站返回请求中恢复 session,可以按照 Servlet 容器支持的配置方式,将 session Cookie 设置为:
SameSite=None; Secure
这种设置要求全程使用 HTTPS。Tomcat、Jetty、Spring Boot 等运行环境及不同版本的配置方式并不相同,不能直接套用同一段配置。
2. 返回 URL 进入了另一个应用或节点
应用部署在多个实例上时,原 session 可能只保存在创建它的节点中。返回请求被转发到其他节点后,看起来就像 session 丢失了。
可以采用以下方案:
- 配置负载均衡会话粘滞
- 使用 Redis 等共享 session 存储
- 使用 Spring Session 等集中式 session 方案
即便使用了共享 session,支付业务的关键数据仍然需要写入数据库。
3. session 已失效
如果用户在支付页面停留太久,原 session 可能已经超时。服务器重启、应用重新部署或 session 存储异常,也可能使 session 消失。
可以调整超时时间,例如:
<session-config>
<session-timeout>30</session-timeout>
</session-config>
这里的单位是分钟。延长超时时间只能减少问题发生的概率,不能保证支付流程可靠。
4. 返回地址与发起支付时使用的站点不一致
对于 Cookie 来说,下面这些地址可能属于不同的作用域:
https://example.com
https://www.example.com
https://pay.example.com
http://example.com
支付前后的协议、域名和端口应尽量保持一致。同时,可以在浏览器开发者工具中检查返回请求是否携带了 JSESSIONID。
推荐的实现方式
发起支付前创建订单,并将后续需要恢复的数据保存到数据库:
String orderNo = UUID.randomUUID().toString();
PaymentOrder order = new PaymentOrder();
order.setOrderNo(orderNo);
order.setUserId(120);
order.setStatus("PENDING");
paymentOrderService.save(order);
// 将 orderNo 作为支付网关支持的商户订单号传入
paymentGatewayClient.createPayment(orderNo);
收到支付网关的浏览器返回请求或服务器回调后,根据商户订单号查询订单:
String orderNo = request.getParameter("orderNo");
PaymentOrder order = paymentOrderService.findByOrderNo(orderNo);
if (order == null) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
return;
}
// 必须验证支付网关签名,并向网关核验支付结果
if (!paymentGatewayService.verifyCallback(request)) {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
paymentOrderService.markPaid(orderNo);
Integer userId = order.getUserId();
具体参数名由支付网关定义,可能是 merchantOrderId、out_trade_no 或 reference。应使用网关提供的商户侧唯一标识字段,不能假定它一定叫 orderNo。
浏览器跳转与服务器回调要分开处理
支付网关通常提供两类通知:
- 浏览器返回地址:用户完成支付后,浏览器跳回商户网站。这个页面可能被关闭、刷新或重复访问。
- 服务器回调地址:支付网关直接请求商户服务器,通常不会携带用户浏览器的
JSESSIONID。
服务器回调本身无法读取用户的 session。最终支付状态应以经过签名验证的服务器通知或主动查询结果为准,不能根据浏览器返回页面或 session 是否存在来判断。
注意事项
- 不要将用户 ID、金额或支付状态直接放入未签名的 URL 参数后直接信任。
- 必须校验支付网关的签名、订单号、金额、币种和商户身份。
- 回调接口需要支持幂等处理,防止重复通知造成重复入账。
- 不建议将
JSESSIONID放入支付回调 URL,否则可能带来 session 泄露或会话固定风险。 session.setAttribute("id", 120);保存的是自动装箱后的Integer,读取时可以转换为Integer,但类型转换无法解决 session 已经变化的问题。- 即使 Cookie 或负载均衡配置已经修复,也应保留数据库订单关联机制,以处理 session 超时、浏览器关闭和异步回调等情况。
备注:内容仅供参考。
