PAYATHON 2026

从 PAYMENT GATEWAY 返回后如何保留 ORIGINAL session?

支付老李

结论

Payment Gateway 通常不会替商户应用保存或恢复 HttpSession。Java Web 应用的会话由 Tomcat 和浏览器中的 JSESSIONID Cookie 共同维护。支付平台不拥有这个会话,也不应接触它。

支付完成后,如果浏览器跳回原来的域名,且 JSESSIONID 仍然有效,Tomcat 通常可以识别原会话。不过,支付流程不能依赖这一点。返回请求可能是跨站 POST,也可能是服务器间回调。用户还可能关闭浏览器、清除 Cookie,Session 也可能已经超时。

更可靠的做法是:

  1. 跳转前在数据库中创建一条“待支付订单”记录。
  2. 生成不可预测的支付关联标识,例如 paymentRef。
  3. 通过支付网关允许的商户字段传递 paymentRef 或订单号。
  4. 支付完成后,根据该标识从数据库中恢复用户、订单和购物车信息。
  5. 使用支付平台的签名验证或服务端查询确认支付结果。
  6. 分开处理浏览器返回页和支付结果通知。

不要把完整 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 超时时间足以覆盖正常支付时长。
  • 返回请求会携带 JSESSIONID Cookie。
  • 多节点部署已配置粘性会话或共享 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 已过期,或支付平台重复发送通知,系统仍能安全、准确地处理订单。

备注:内容仅供参考。