如何从 REST controller 使用 POST 重定向到支付网关
结论
HTTP 没有”重定向并附带一组新的 POST 参数”这种机制。sendRedirect() 返回重定向响应,浏览器通常会使用 GET 请求访问目标地址,服务器无法通过这种方式给目标请求添加新的 POST body。
支付网关要求浏览器通过 POST 提交参数时,一般按下面的流程处理:
- 浏览器把订单提交给自己的服务器。
- 服务器校验并保存订单,生成支付参数和签名。
- 服务器返回一个包含 hidden fields 的过渡页面。
- 过渡页面显示”正在跳转到支付页面”,同时自动向支付网关提交表单。
如果最初通过 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 创建订单,可以分成两个阶段:
- 使用
fetch()调用 REST API 创建订单。 - API 返回一个短期有效且只能使用一次的
checkoutToken。 - 前端通过普通 POST 表单,把该 token 提交给自己的跳转接口。
- 跳转接口根据 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。相比在浏览器中传递完整的支付参数,这种方式通常更简单,也更便于控制敏感数据。
备注:内容仅供参考。
