如何将 3rd party API 重定向到 React 并接收响应数据?
结论
支付结果应由部署在云端的 .NET Core Web API 接收和确认,不应交给 React 直接处理。
整个流程可以分为两条通道:
- 服务端回调(Webhook/Notify URL):支付网关直接通知后端。后端验签、核对订单并更新支付状态。确认支付结果时,应以这条通道为准。
- 浏览器返回(Return URL):支付完成后,客户的浏览器跳转到 React 结果页面,再由 React 向后端查询订单状态并显示结果。
API 部署在云服务器上不会导致 Request.Form 失效。支付网关或客户浏览器会通过公网向 API 发送回调请求,这与客户使用哪种设备无关。需要确认的是,支付网关采用服务器回调还是浏览器表单跳转,以及它对签名验证有哪些要求。
推荐的交互流程
React
│ 1. 创建支付
▼
.NET Core Web API
│ 2. 验证订单,在数据库创建 Pending 支付记录
│ 3. 向支付网关创建交易
▼
Payment Gateway
│
├─ 4. 服务端回调 Webhook ──> Web API 验签并更新订单
│
└─ 5. 浏览器跳转 Return URL ──> React 支付结果页
│
└─ 6. 调用 Web API 查询支付状态
React 页面收到的查询参数不能作为支付成功的证明,因为用户可以修改浏览器地址、查询参数和前端状态。
创建支付时由后端生成回调地址
不要直接信任前端传入的 success URL、fail URL 和通知地址,否则可能造成开放重定向漏洞。后端应根据配置生成固定地址,或者只接受预先配置的域名。
public sealed record CreatePaymentRequest(Guid OrderId);
public sealed record CreatePaymentResponse(string RedirectUrl);
[ApiController]
[Route("api/payments")]
public class PaymentsController : ControllerBase
{
private readonly IConfiguration _configuration;
private readonly IPaymentGateway _paymentGateway;
private readonly IOrderService _orders;
public PaymentsController(
IConfiguration configuration,
IPaymentGateway paymentGateway,
IOrderService orders)
{
_configuration = configuration;
_paymentGateway = paymentGateway;
_orders = orders;
}
[HttpPost]
public async Task<ActionResult<CreatePaymentResponse>> Create(
CreatePaymentRequest request,
CancellationToken cancellationToken)
{
var order = await _orders.GetForPaymentAsync(
request.OrderId,
cancellationToken);
if (order is null)
return NotFound();
if (order.IsPaid)
return Conflict("Order has already been paid.");
var apiBaseUrl = _configuration["PublicUrls:ApiBaseUrl"]!;
var frontendBaseUrl = _configuration["PublicUrls:FrontendBaseUrl"]!;
var gatewayRequest = new GatewayPaymentRequest
{
MerchantReference = order.Id.ToString(),
Amount = order.TotalAmount,
Currency = order.Currency,
// 服务器之间的支付通知
NotifyUrl = $"{apiBaseUrl}/api/payments/webhook",
// 用户浏览器完成支付后的返回地址
ReturnUrl =
$"{frontendBaseUrl}/payment/result?orderId={order.Id}"
};
var result = await _paymentGateway.CreatePaymentAsync(
gatewayRequest,
cancellationToken);
await _orders.SaveGatewayTransactionIdAsync(
order.Id,
result.TransactionId,
cancellationToken);
return Ok(new CreatePaymentResponse(result.RedirectUrl));
}
}
React 收到 RedirectUrl 后,应执行顶层页面跳转:
async function checkout(orderId) {
const response = await fetch("/api/payments", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
credentials: "include",
body: JSON.stringify({ orderId })
});
if (!response.ok) {
throw new Error("Unable to create payment");
}
const { redirectUrl } = await response.json();
// 支付页面通常需要顶层跳转,而不是 Ajax 请求
window.location.assign(redirectUrl);
}
在 Web API 中接收支付回调
如果网关以表单形式发送通知,可以使用 Request.Form 接收。如果网关发送 JSON,则应从请求体中读取 JSON。具体字段、签名算法和请求格式必须以支付网关的接口文档为准。
下面的代码展示了通用结构。VerifySignature 只是接口占位,不能代替网关规定的验签实现:
[ApiController]
[Route("api/payments")]
public class PaymentCallbacksController : ControllerBase
{
private readonly IPaymentGateway _paymentGateway;
private readonly IOrderService _orders;
public PaymentCallbacksController(
IPaymentGateway paymentGateway,
IOrderService orders)
{
_paymentGateway = paymentGateway;
_orders = orders;
}
[HttpPost("webhook")]
public async Task<IActionResult> Webhook(
CancellationToken cancellationToken)
{
var form = await Request.ReadFormAsync(cancellationToken);
if (!_paymentGateway.VerifySignature(form))
return Unauthorized();
var notification = _paymentGateway.ParseNotification(form);
var order = await _orders.FindByMerchantReferenceAsync(
notification.MerchantReference,
cancellationToken);
if (order is null)
return NotFound();
// 必须使用数据库中的订单数据核对,不能只相信回调中的金额
if (notification.Amount != order.TotalAmount ||
notification.Currency != order.Currency)
{
return BadRequest("Payment details do not match the order.");
}
if (notification.IsSuccessful)
{
// 应实现为幂等操作,重复回调不能重复扣库存或发货
await _orders.MarkAsPaidAsync(
order.Id,
notification.TransactionId,
cancellationToken);
}
else
{
await _orders.RecordPaymentFailureAsync(
order.Id,
notification.TransactionId,
cancellationToken);
}
// 某些网关要求返回特定文本,例如 "OK" 或 "success"。
// 应按照该网关文档调整响应内容。
return Ok();
}
}
如果网关发送 JSON,可以改用模型绑定:
[HttpPost("webhook")]
public async Task<IActionResult> Webhook(
[FromBody] GatewayNotification notification,
CancellationToken cancellationToken)
{
// 验签、核对交易和更新订单
return Ok();
}
部分支付网关会根据未经修改的原始请求体计算签名。遇到这种情况,应先读取原始字节并验签,然后再反序列化数据。不要先调整字段顺序或重新生成 JSON。
React 返回页面只负责展示
支付网关将浏览器带回 React 后,页面可以从 URL 中取得 orderId,再向自己的后端查询最终状态:
import { useEffect, useState } from "react";
export default function PaymentResult() {
const [status, setStatus] = useState("checking");
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const orderId = params.get("orderId");
if (!orderId) {
setStatus("invalid");
return;
}
let cancelled = false;
let timerId;
async function checkStatus() {
const response = await fetch(`/api/payments/${orderId}/status`, {
credentials: "include"
});
if (!response.ok) {
if (!cancelled) setStatus("error");
return;
}
const result = await response.json();
if (cancelled) return;
setStatus(result.status);
// 浏览器返回可能早于 Webhook,因此 Pending 时可以短暂轮询
if (result.status === "Pending") {
timerId = window.setTimeout(checkStatus, 2000);
}
}
checkStatus();
return () => {
cancelled = true;
window.clearTimeout(timerId);
};
}, []);
if (status === "Paid") return <p>支付成功</p>;
if (status === "Failed") return <p>支付失败,请重试</p>;
if (status === "Pending" || status === "checking") {
return <p>正在确认支付结果……</p>;
}
return <p>暂时无法确认支付状态</p>;
}
后端查询接口还应检查当前用户是否有权查看该订单:
[HttpGet("{orderId:guid}/status")]
public async Task<IActionResult> GetStatus(
Guid orderId,
CancellationToken cancellationToken)
{
var userId = User.FindFirst("sub")?.Value;
var order = await _orders.GetOwnedOrderAsync(
orderId,
userId,
cancellationToken);
if (order is null)
return NotFound();
return Ok(new
{
orderId = order.Id,
status = order.PaymentStatus
});
}
如果支付网关只有 success URL 和 fail URL
有些网关没有独立的 Webhook,只会让浏览器向指定地址发起 GET 请求或提交 POST 表单。这时可以把 success/fail URL 指向后端:
https://api.example.com/api/payments/return/success
https://api.example.com/api/payments/return/fail
后端接收并验证参数后,再将浏览器重定向到 React:
[HttpPost("return/success")]
public async Task<IActionResult> SuccessReturn(
CancellationToken cancellationToken)
{
var form = await Request.ReadFormAsync(cancellationToken);
if (!_paymentGateway.VerifySignature(form))
return Unauthorized();
var result = _paymentGateway.ParseNotification(form);
// 如果网关提供交易查询 API,最好再向网关查询一次最终状态
await _orders.ApplyPaymentResultAsync(result, cancellationToken);
var frontendUrl =
$"https://shop.example.com/payment/result" +
$"?orderId={Uri.EscapeDataString(result.MerchantReference)}";
return Redirect(frontendUrl);
}
这种方式可以工作,但通常没有服务器回调可靠。客户可能在支付后关闭页面或断网,浏览器重定向也可能被阻止。如果网关提供交易查询接口,后端应主动查询并确认结果,不能只依赖浏览器返回。
需要特别注意的事项
- 必须验签。不能仅凭
status=success、HTTP 来源地址或前端参数认定支付成功。 - 核对交易信息。至少要核对商户订单号、网关交易号、金额、币种、商户账号和支付状态。
- 使用 HTTPS。回调地址和前端返回地址都应是公网可访问的 HTTPS URL。
- 保证幂等性。网关可能重复发送通知,同一交易只能执行一次记账、扣库存、发货或积分处理。
- 先保存订单再跳转。调用网关前,应在数据库中创建
Pending状态的支付记录。 - 不要信任前端 URL。回调地址和返回地址应来自后端配置。如果必须动态设置,就要使用严格的域名白名单。
- 不要把密钥放进 React。商户密钥、签名密钥和网关管理凭证只能保存在后端。
- 浏览器返回不代表支付已经完成。React 页面只能展示后端保存的状态,或后端向网关查询后得到的状态。
- 处理通知乱序。失败跳转、成功回调和退款通知可能以不同顺序到达,需要按照网关定义的状态转换规则处理。
- 记录审计信息。保存交易号、订单号、通知时间、验签结果和处理结果,但不要记录银行卡信息、密钥等敏感数据。
- 按网关要求响应。许多网关只有收到指定状态码或响应文本后才会停止重试。
云端 API 适合接收支付回调。React 负责发起结账和展示状态,.NET Core Web API 负责验签、核对交易并持久化支付结果。最终应以支付网关的服务器回调或交易查询结果为准。
备注:内容仅供参考。
