PAYATHON 2026

如何将 3rd party API 重定向到 React 并接收响应数据?

支付阿杰

结论

支付结果应由部署在云端的 .NET Core Web API 接收和确认,不应交给 React 直接处理。

整个流程可以分为两条通道:

  1. 服务端回调(Webhook/Notify URL):支付网关直接通知后端。后端验签、核对订单并更新支付状态。确认支付结果时,应以这条通道为准。
  2. 浏览器返回(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 负责验签、核对交易并持久化支付结果。最终应以支付网关的服务器回调或交易查询结果为准。

备注:内容仅供参考。