PAYATHON 2026

如何从 REST controller 使用 POST 重定向到支付网关

支付小周

结论

HTTP 没有”重定向并附带一组新的 POST 参数”这种机制。sendRedirect() 返回重定向响应,浏览器通常会使用 GET 请求访问目标地址,服务器无法通过这种方式给目标请求添加新的 POST body。

支付网关要求浏览器通过 POST 提交参数时,一般按下面的流程处理:

  1. 浏览器把订单提交给自己的服务器。
  2. 服务器校验并保存订单,生成支付参数和签名。
  3. 服务器返回一个包含 hidden fields 的过渡页面。
  4. 过渡页面显示”正在跳转到支付页面”,同时自动向支付网关提交表单。

如果最初通过 fetch 或 AJAX 调用 REST API,接口返回的 HTML 不会自动替换当前页面,也不会触发顶层页面跳转。这种情况下,需要改用普通表单导航,或者等接口调用成功后,由前端创建并提交一个普通表单。

为什么 sendRedirect() 不适用

例如:

resp.sendRedirect("https://payment.example.com/pay");

它返回的响应大致如下:

HTTP/1.1 302 Found
Location: https://payment.example.com/pay

浏览器接着访问 Location 指定的地址,但不会自动带上原请求中的 POST 参数。服务器也不能借助这个重定向,为新请求构造另一组 POST 参数。

307 Temporary Redirect 和 308 Permanent Redirect 可以要求浏览器保留原请求的方法和请求体,但只能转发原始 POST body,不能把它替换成支付网关所需的新参数。直接把用户提交的原始数据转发给第三方通常也不安全,因此不适合用来完成支付跳转。

服务器直接向网关发送 HTTP POST 也不能让页面发生跳转。这个请求只发生在服务器和支付网关之间,用户的浏览器不会因此打开网关页面。

Spring MVC 实现方式

支付跳转页面需要以 HTML 形式返回,因此这里适合使用 @Controller,而不是默认返回 JSON 的 @RestController。

@Controller
@RequestMapping("/checkout")
public class CheckoutController {

    private final OrderService orderService;
    private final PaymentService paymentService;

    public CheckoutController(
            OrderService orderService,
            PaymentService paymentService) {
        this.orderService = orderService;
        this.paymentService = paymentService;
    }

    @PostMapping
    public ModelAndView checkout(@Valid CheckoutForm checkoutForm) {
        Order order = orderService.createOrder(checkoutForm);

        PaymentRequest paymentRequest =
                paymentService.createPaymentRequest(order);

        ModelAndView modelAndView = new ModelAndView("payment-redirect");
        modelAndView.addObject(
                "gatewayUrl",
                paymentRequest.getGatewayUrl()
        );
        modelAndView.addObject(
                "paymentFields",
                paymentRequest.getFields()
        );

        return modelAndView;
    }
}

PaymentRequest 用来保存网关地址和需要提交的字段:

public class PaymentRequest {

    private final String gatewayUrl;
    private final Map<String, String> fields;

    public PaymentRequest(
            String gatewayUrl,
            Map<String, String> fields) {
        this.gatewayUrl = gatewayUrl;
        this.fields = Map.copyOf(fields);
    }

    public String getGatewayUrl() {
        return gatewayUrl;
    }

    public Map<String, String> getFields() {
        return fields;
    }
}

可以使用 Thymeleaf 生成 payment-redirect.html:

<!DOCTYPE html>
<html lang="zh-CN"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>正在跳转到支付页面</title>
    <style>
        body {
            margin: 0;
            min-height: 100vh;
            display: grid;
            place-items: center;
            font-family: sans-serif;
            background: #f5f7fa;
        }

        .loading {
            text-align: center;
        }

        .spinner {
            width: 36px;
            height: 36px;
            margin: 0 auto 16px;
            border: 4px solid #d9d9d9;
            border-top-color: #1677ff;
            border-radius: 50%;
            animation: spin 0.8s linear infinite;
        }

        @keyframes spin {
            to {
                transform: rotate(360deg);
            }
        }
    </style>
</head>
<body>
<div class="loading">
    <div class="spinner"></div>
    <p>订单已创建,正在跳转到支付页面……</p>
</div>

<form id="paymentForm"
      th:action="${gatewayUrl}"
      method="post">
    <input th:each="entry : ${paymentFields}"
           type="hidden"
           th:name="${entry.key}"
           th:value="${entry.value}">
</form>

<script>
    document.getElementById("paymentForm").submit();
</script>

<noscript>
    <p>浏览器未启用 JavaScript,请点击下面的按钮继续。</p>
    <button type="submit" form="paymentForm">
        前往支付
    </button>
</noscript>
</body>
</html>

浏览器收到这个页面后,会先显示 loading 内容,然后自动向支付网关发起 POST 请求。

如果项目启用了严格的 Content Security Policy,禁止 inline script,就需要把自动提交代码放进外部 JavaScript 文件,或者按照项目的 CSP 方案使用 nonce。

原页面立即显示 loading

如果希望用户点击按钮后,在服务器处理订单期间立即看到 loading 状态,可以监听原表单的提交事件,显示遮罩并禁用按钮:

<form id="checkoutForm"
      method="post"
      action="/checkout">
    <!-- 用户填写的字段 -->

    <button id="submitButton" type="submit">
        提交订单
    </button>
</form>

<div id="loading" hidden>
    正在创建订单,请稍候……
</div>

<script>
    const form = document.getElementById("checkoutForm");
    const button = document.getElementById("submitButton");
    const loading = document.getElementById("loading");

    form.addEventListener("submit", function () {
        button.disabled = true;
        loading.hidden = false;
    });
</script>

这里必须保留表单的正常页面提交。如果用 fetch() 调用 /checkout,即使服务器返回了包含自动提交表单的页面,这些内容也只会成为 JavaScript 收到的响应文本,不会替换当前页面。

必须保留 REST API 时的处理

如果必须先通过 REST API 创建订单,可以分成两个阶段:

  1. 使用 fetch() 调用 REST API 创建订单。
  2. API 返回一个短期有效且只能使用一次的 checkoutToken。
  3. 前端通过普通 POST 表单,把该 token 提交给自己的跳转接口。
  4. 跳转接口根据 token 读取服务器保存的支付参数,再返回自动提交页面。

前端示例:

async function createOrder(data) {
    const loading = document.getElementById("loading");
    loading.hidden = false;

    try {
        const response = await fetch("/api/orders", {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: JSON.stringify(data)
        });

        if (!response.ok) {
            throw new Error("创建订单失败");
        }

        const result = await response.json();

        const form = document.createElement("form");
        form.method = "post";
        form.action = "/checkout/forward";

        const token = document.createElement("input");
        token.type = "hidden";
        token.name = "checkoutToken";
        token.value = result.checkoutToken;

        form.appendChild(token);
        document.body.appendChild(form);
        form.submit();
    } catch (error) {
        loading.hidden = true;
        throw error;
    }
}

对应的服务器接口:

@PostMapping("/checkout/forward")
public ModelAndView forwardToGateway(
        @RequestParam String checkoutToken) {

    PaymentRequest paymentRequest =
            paymentService.getPaymentRequest(checkoutToken);

    ModelAndView modelAndView =
            new ModelAndView("payment-redirect");

    modelAndView.addObject(
            "gatewayUrl",
            paymentRequest.getGatewayUrl()
    );
    modelAndView.addObject(
            "paymentFields",
            paymentRequest.getFields()
    );

    return modelAndView;
}

checkoutToken 应当随机生成、短期有效并且只能使用一次。它还必须绑定当前订单或用户,不能直接采用可预测的数据库主键。

安全注意事项

hidden field 只是不直接显示在页面上,并不代表其中的数据是保密的。用户仍然可以通过浏览器开发者工具、页面源代码或网络面板查看和修改这些字段。因此:

  • 不要把商户密钥、API secret、私钥等敏感信息放入 hidden field。
  • 金额、订单号、商户号等重要字段应由服务器生成。
  • 按照支付网关的要求对参数签名,不能只信任前端提交的值。
  • 网关地址应来自服务器配置或固定白名单,不能直接接受用户输入,否则可能造成开放重定向或数据泄露。
  • 创建订单时应防止重复提交,可以使用幂等键或订单状态限制。
  • 不能只根据浏览器返回页面判断支付成功,应以支付网关的服务端通知或主动查询结果为准。
  • 收到网关回调后,必须验证签名、金额、币种、商户号和订单状态。
  • 整个支付流程应使用 HTTPS,并根据应用架构配置 CSRF 防护、Session 策略和安全响应头。

如果支付网关支持”服务器创建支付会话,返回托管支付页面 URL”的模式,应优先使用这种方式。服务器通过后台 API 把支付参数发送给网关,浏览器只需跳转到网关返回的支付 URL。相比在浏览器中传递完整的支付参数,这种方式通常更简单,也更便于控制敏感数据。

备注:内容仅供参考。