从 PAYMENT GATEWAY 返回后如何保留 ORIGINAL session?
结论
Payment Gateway 通常不会替商户应用保存或恢复 HttpSession。Java Web 应用的会话由 Tomcat 和浏览器中的 JSESSIONID Cookie 共同维护。支付平台不拥有这个会话,也不应接触它。
支付完成后,如果浏览器跳回原来的域名,且 JSESSIONID 仍然有效,Tomcat 通常可以识别原会话。不过,支付流程不能依赖这一点。返回请求可能是跨站 POST,也可能是服务器间回调。用户还可能关闭浏览器、清除 Cookie,Session 也可能已经超时。
更可靠的做法是:
- 跳转前在数据库中创建一条“待支付订单”记录。
- 生成不可预测的支付关联标识,例如
paymentRef。 - 通过支付网关允许的商户字段传递
paymentRef或订单号。 - 支付完成后,根据该标识从数据库中恢复用户、订单和购物车信息。
- 使用支付平台的签名验证或服务端查询确认支付结果。
- 分开处理浏览器返回页和支付结果通知。
不要把完整 Session、购物车内容或 JSESSIONID 传给支付平台。
为什么原 Session 会丢失
Tomcat 一般通过名为 JSESSIONID 的 Cookie 查找会话。出现以下任何一种情况,返回时都可能无法取得原 Session:
- 返回地址的域名、协议或应用上下文发生变化,例如从
www.example.com返回到example.com。 - Session 在支付过程中超时。
- 浏览器禁止或清除了 Cookie。
- 支付平台使用跨站
POST返回,浏览器因SameSite策略没有发送 Session Cookie。 - 支付平台服务器直接回调商户服务器,没有经过用户浏览器,因此不会携带用户的 Cookie。
- 应用部署在多个 Tomcat 节点,但没有配置粘性会话或共享会话。
- 应用重启或重新部署,且 Session 没有持久化。
- 返回地址使用了错误的 Cookie Path 或不同的子域名。
可以在返回 Servlet 中检查是否恢复了旧会话:
HttpSession session = request.getSession(false);
if (session == null) {
// 没有收到有效的 JSESSIONID,或者对应 Session 已过期
}
这里应使用 getSession(false)。调用 getSession() 时,Tomcat 会自动创建一个新 Session,很容易让人误以为只是原 Session 中的数据消失了。
推荐的支付关联方式
跳转到支付平台前,不要只把信息放在 Session 中:
session.setAttribute("cart", cart);
session.setAttribute("user", user);
应先把支付所需的业务快照保存到数据库,至少包括:
- 商户订单号
- 不可预测的支付关联标识
- 用户 ID
- 订单明细或购物车快照
- 应付金额及币种
- 支付状态
- 创建时间和过期时间
- 支付平台交易号
- 已处理通知的标记
保存“购物车快照”很重要。用户跳转支付后,当前购物车可能继续变化,因此不能在返回时根据购物车重新计算结算金额。
跳转前的示例
以下代码只用于说明流程。数据库访问方法和支付网关参数需要根据实际项目实现:
@WebServlet("/payment/start")
public class PaymentStartServlet extends HttpServlet {
private final PaymentRepository paymentRepository =
new PaymentRepository();
@Override
protected void doPost(
HttpServletRequest request,
HttpServletResponse response) throws IOException {
HttpSession session = request.getSession(false);
if (session == null) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
User user = (User) session.getAttribute("user");
Cart cart = (Cart) session.getAttribute("cart");
if (user == null || cart == null || cart.isEmpty()) {
response.sendError(
HttpServletResponse.SC_BAD_REQUEST,
"No payable cart");
return;
}
String orderId = createMerchantOrderId();
String paymentRef = createSecureReference();
// 金额必须在服务端计算,不能采用浏览器提交的金额。
BigDecimal amount = calculateAmount(cart);
paymentRepository.createPendingPayment(
orderId,
paymentRef,
user.getId(),
cart.createSnapshot(),
amount,
"CNY");
// Session 可以保存辅助信息,但不能作为唯一关联依据。
session.setAttribute("currentPaymentRef", paymentRef);
String gatewayUrl = buildGatewayUrl(
orderId,
paymentRef,
amount);
response.sendRedirect(gatewayUrl);
}
private String createSecureReference() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(bytes);
}
}
如果项目必须运行在 Java 7 上,由于 java.util.Base64 不可用,需要改用项目已有的 Base64 URL-safe 编码库。不能因此换成容易预测的时间戳或自增数字作为安全关联令牌。
实际接入时,应优先使用支付网关定义的 merchantReference、orderId、customData 等字段。具体字段名和长度限制以该网关的接口文档为准。
返回页面如何恢复业务信息
浏览器返回时可以尝试读取 Session,但业务信息应以数据库中的支付记录为准:
@WebServlet("/payment/return")
public class PaymentReturnServlet extends HttpServlet {
private final PaymentRepository paymentRepository =
new PaymentRepository();
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException {
String paymentRef = request.getParameter("paymentRef");
if (paymentRef == null || paymentRef.isEmpty()) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
return;
}
PaymentRecord payment =
paymentRepository.findByPaymentRef(paymentRef);
if (payment == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
HttpSession session = request.getSession(false);
if (session != null) {
User user = (User) session.getAttribute("user");
if (user != null &&
!user.getId().equals(payment.getUserId())) {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
}
request.setAttribute("payment", payment);
request.getRequestDispatcher("/WEB-INF/payment-result.jsp")
.forward(request, response);
}
}
paymentRef 只能用于定位记录,不能单独证明支付成功。即使用户把返回地址中的参数改成 status=SUCCESS,应用也不能据此将订单标记为已支付。
服务端通知才是支付结果的主要依据
多数支付系统会提供 Webhook、异步通知或支付状态查询接口。支付状态应优先通过这些服务端入口更新:
@WebServlet("/payment/notify")
public class PaymentNotificationServlet extends HttpServlet {
private final PaymentRepository paymentRepository =
new PaymentRepository();
private final GatewayClient gatewayClient =
new GatewayClient();
@Override
protected void doPost(
HttpServletRequest request,
HttpServletResponse response) throws IOException {
byte[] rawBody = readRequestBody(request);
if (!gatewayClient.verifySignature(
rawBody, request.getHeader("X-Gateway-Signature"))) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
GatewayNotification notification =
gatewayClient.parseNotification(rawBody);
PaymentRecord payment = paymentRepository.findByOrderId(
notification.getMerchantOrderId());
if (payment == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
if (!payment.getAmount().equals(notification.getAmount()) ||
!payment.getCurrency().equals(notification.getCurrency())) {
response.sendError(HttpServletResponse.SC_CONFLICT);
return;
}
// 方法内部应使用事务和条件更新,保证重复通知不会重复记账。
paymentRepository.markPaidIfPending(
payment.getOrderId(),
notification.getGatewayTransactionId());
response.setStatus(HttpServletResponse.SC_OK);
}
}
这里的签名头名称、签名算法和通知格式都只是示意。实际项目必须使用对应 Payment Gateway 官方规定的验证方法。如果网关支持主动查询,在通知信息不足或状态可疑时,应通过服务端 API 查询实际交易状态。
如果确实希望浏览器返回后仍使用原 Session
需要同时满足以下条件:
- 返回 URL 与原应用使用兼容的域名和 Cookie Path。
- 全程使用 HTTPS。
- Session 超时时间足以覆盖正常支付时长。
- 返回请求会携带
JSESSIONIDCookie。 - 多节点部署已配置粘性会话或共享 Session。
- 应用没有在支付过程中重启或丢失 Session 数据。
Tomcat 的 Session 超时时间可以在 web.xml 中配置:
<session-config>
<session-timeout>30</session-timeout>
</session-config>
单位是分钟。延长超时时间只能降低 Session 超时的概率,无法解决服务器回调、Cookie 丢失或跨站 Cookie 限制,因此不能代替数据库关联方案。
如果支付平台通过跨站 POST 返回,还要检查浏览器的 SameSite Cookie 行为。较新的浏览器可能不会在这类请求中发送默认的 Session Cookie。是否需要设置 SameSite=None; Secure,取决于实际返回方式、支持的浏览器范围和应用容器配置。Tomcat 7 的不同维护版本对 Cookie 属性的支持并不完全一致,需要根据当前使用的具体版本确认。也可以通过受控的响应头配置实现,但不能重复生成互相冲突的 Set-Cookie。
不应采用的做法
不要通过返回地址传递 Session ID:
https://merchant.example/payment/return;jsessionid=...
也不要把 JSESSIONID 放入支付网关的自定义参数。Session ID 属于认证凭证,可能被记录在浏览器历史、访问日志、分析系统或第三方页面中,带来会话劫持风险。
同样,不建议把序列化后的用户对象、购物车或金额放进隐藏字段,再在返回时原样取回。客户端与第三方页面之间传递的数据可能被修改。即使对数据加密,也会增加密钥管理、重放攻击防护和数据过期处理的复杂度。
Session 应用于保存页面交互所需的临时状态,支付流程则应以数据库中的订单和支付记录为业务事实来源。这样,即使用户没有返回商户页面、Session 已过期,或支付平台重复发送通知,系统仍能安全、准确地处理订单。
备注:内容仅供参考。
