PAYATHON 2026

如何从 callback URL 的响应中提取支付数据?

支付小周

结论

支付回调应该由 Express 提供独立的服务端接口接收,例如:

POST https://example.com/payment-callback

不要让 /payment 请求一直等待回调。这是两个互相独立的 HTTP 请求,可能由不同进程处理,间隔也可能达到几分钟甚至更久。

React Router 适合处理用户浏览器访问的 completeUrl、cancelUrl 等页面,不适合接收支付平台发来的服务器回调。支付结果应该写入数据库,不能暂存在服务器内存变量中。

完整流程可以这样设计:

  1. 前端调用后端 /payment 创建支付。
  2. 后端保存本地订单和支付平台的 payment ID。
  3. 后端立即把支付跳转地址或本地订单号返回给前端。
  4. 用户在支付页面完成付款。
  5. 支付平台向 /payment-callback 发送 POST 请求。
  6. 后端验证回调、更新数据库,并立即返回成功响应。
  7. React 通过查询接口获取订单的最新支付状态。

为什么不能在 /payment 中等待回调

调用 /payment 后,这次 HTTP 请求通常只能维持几秒到几十秒。支付回调要等用户完成付款后才会发出,具体时间无法预测。

两个请求的方向也不同:

Browser -> Your API /payment
Payment provider -> Your API /payment-callback

即使使用 await,也只能等待当前函数中已有的 Promise。将来某个时间到达服务器的另一个 HTTP 请求,不会自动变成 /payment 可以等待的 Promise。

强行让 /payment 请求一直保持连接还会产生这些问题:

  • 浏览器、反向代理或负载均衡器可能超时。
  • 用户可能已经关闭页面,但支付仍在继续。
  • 支付平台可能重复发送回调。
  • 多实例部署时,创建支付和接收回调的请求可能落到不同服务器上。
  • 服务重启后,保存在内存中的等待状态会丢失。

所以,支付流程应该采用持久化的异步状态流转,而不是依靠一个始终不结束的长请求。

建议的数据模型

至少需要保存这些信息:

Booking / Order
- id
- amount
- currency
- paymentStatus
- providerPaymentId
- createdAt
- paidAt

paymentStatus 可以按业务需要设置为:

pending
processing
paid
failed
canceled

状态名称不一定要完全照搬这组值,但要明确哪些状态属于终态,并阻止不合法的状态回退。例如,订单已经确认 paid 后,不能因为延迟到达的旧回调而重新变成 pending。

Express 实现示例

下面的代码只展示整体结构。实际使用的字段、签名算法、成功响应格式和状态名称,都要以支付平台的 API 文档为准。

创建支付

server.express.post("/payment", async (req, res, next) => {
  try {
    const booking = await bookingRepository.findById(req.body.bookingId);

    if (!booking) {
      return res.status(404).json({ error: "Booking not found" });
    }

    // 金额应由服务端根据 booking 计算,不要直接信任前端传入的金额。
    const order = await orderRepository.create({
      bookingId: booking.id,
      amount: booking.totalAmount,
      currency: "SEK",
      paymentStatus: "pending"
    });

    const paymentHeader = {
      headers: {
        Authorization: `Bearer ${process.env.PAYMENT_API_TOKEN}`,
        "Content-Type": "application/json"
      }
    };

    const body = {
      payment: {
        amount: order.amount,
        currency: order.currency,

        // 如果平台支持 merchantReference 之类的字段,
        // 可在这里传入本地 order.id,便于关联回调。
        merchantReference: order.id,

        urls: {
          hostUrls: ["https://example.com"],
          paymentUrl: "https://example.com/perform-payment",
          completeUrl: `https://example.com/payment-completed?orderId=${order.id}`,
          cancelUrl: `https://example.com/payment-canceled?orderId=${order.id}`,
          callbackUrl: "https://example.com/payment-callback",
          logoUrl: "https://example.com/logo.png",
          termsOfServiceUrl: "https://example.com/terms.pdf"
        }
      }
    };

    const createResponse = await axios.post(
      `${process.env.PAYMENT_URL}/psp/swish/payments`,
      body,
      paymentHeader
    );

    const providerPaymentId = createResponse.data.payment.id;

    await orderRepository.update(order.id, {
      providerPaymentId,
      paymentStatus: "processing"
    });

    const salesResponse = await axios.post(
      `${process.env.PAYMENT_URL}/${providerPaymentId}/sales`,
      {
        transaction: {
          msisdn: "+46739000001"
        }
      },
      paymentHeader
    );

    return res.status(201).json({
      orderId: order.id,
      paymentId: providerPaymentId,
      paymentUrl: salesResponse.data.paymentUrl
    });
  } catch (error) {
    next(error);
  }
});

这里要注意 Axios 的参数顺序。axios.post 的调用格式是:

axios.post(url, data, config);

如果 paymentHeader 是请求配置,就应该作为第三个参数传入,不能放进请求正文:

await axios.post(
  url,
  {
    transaction: {
      msisdn: "+46739000001"
    }
  },
  paymentHeader
);

配置对象通常类似:

const paymentHeader = {
  headers: {
    Authorization: "Bearer ...",
    "Content-Type": "application/json"
  }
};

接收支付回调

server.express.post("/payment-callback", async (req, res) => {
  try {
    // 字段名称仅为示例,应替换为支付平台实际发送的结构。
    const {
      paymentId,
      status,
      transactionId,
      merchantReference
    } = req.body;

    if (!paymentId || !status) {
      return res.status(400).json({ error: "Invalid callback payload" });
    }

    // 必须按支付平台文档验证签名、证书或鉴权信息。
    const valid = verifyPaymentCallback(req);

    if (!valid) {
      return res.status(401).json({ error: "Invalid callback signature" });
    }

    const order = merchantReference
      ? await orderRepository.findById(merchantReference)
      : await orderRepository.findByProviderPaymentId(paymentId);

    if (!order) {
      return res.status(404).json({ error: "Order not found" });
    }

    if (status === "PAID") {
      await orderRepository.markPaidOnce(order.id, {
        providerPaymentId: paymentId,
        providerTransactionId: transactionId,
        paidAt: new Date()
      });
    } else if (status === "FAILED") {
      await orderRepository.markFailedUnlessPaid(order.id);
    } else if (status === "CANCELED") {
      await orderRepository.markCanceledUnlessPaid(order.id);
    }

    // 尽快确认已经收到回调。
    return res.sendStatus(204);
  } catch (error) {
    console.error("Payment callback failed", error);

    // 返回非 2xx 后,支付平台通常会重试,但具体行为以平台文档为准。
    return res.sendStatus(500);
  }
});

verifyPaymentCallback 不能脱离支付平台的要求自行实现。不同平台可能采用:

  • HMAC 签名;
  • 请求头中的 token;
  • mTLS 客户端证书;
  • 非对称数字签名;
  • 收到回调后,再调用支付查询 API 进行确认。

具体方案必须严格遵循接入平台的文档。只检查请求正文中的 status 并不安全。

某些签名算法要求使用未经 JSON 解析的原始请求体进行验证。这种情况下,需要为回调路由保留 raw body,并留意 Express 中间件的注册顺序。例如:

server.express.post(
  "/payment-callback",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const rawBody = req.body;
    const signature = req.get("X-Payment-Signature");

    if (!verifySignature(rawBody, signature)) {
      return res.sendStatus(401);
    }

    const callback = JSON.parse(rawBody.toString("utf8"));

    // 处理 callback
    return res.sendStatus(204);
  }
);

是否需要 raw body,以及签名请求头的具体名称,仍由支付平台的规则决定。

React 应该负责什么

completeUrl 和 cancelUrl 通常是用户浏览器的跳转地址,可以交给 React Router 处理:

<Routes>
  <Route path="/payment-completed" element={<PaymentCompleted />} />
  <Route path="/payment-canceled" element={<PaymentCanceled />} />
</Routes>

不过,浏览器跳转到 completeUrl 时,服务端不一定已经收到回调,因此不能仅凭这次跳转认定支付成功。用户可以手动访问这个 URL,支付平台的回调也可能晚于浏览器跳转。

完成页面应该读取 orderId,再向后端查询支付状态:

import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";

function PaymentCompleted() {
  const [searchParams] = useSearchParams();
  const orderId = searchParams.get("orderId");
  const [status, setStatus] = useState("loading");

  useEffect(() => {
    if (!orderId) {
      setStatus("invalid");
      return;
    }

    let timer;

    const loadStatus = async () => {
      const response = await fetch(`/api/orders/${orderId}/payment-status`);
      const data = await response.json();

      setStatus(data.paymentStatus);

      if (data.paymentStatus === "pending" ||
          data.paymentStatus === "processing") {
        timer = setTimeout(loadStatus, 2000);
      }
    };

    loadStatus();

    return () => clearTimeout(timer);
  }, [orderId]);

  if (status === "paid") {
    return <p>Payment confirmed.</p>;
  }

  if (status === "failed" || status === "canceled") {
    return <p>Payment was not completed.</p>;
  }

  return <p>Confirming payment...</p>;
}

对应的后端查询接口可以写成:

server.express.get(
  "/api/orders/:orderId/payment-status",
  async (req, res) => {
    const order = await orderRepository.findById(req.params.orderId);

    if (!order) {
      return res.status(404).json({ error: "Order not found" });
    }

    // 实际项目中还应验证当前用户是否有权查看这个订单。
    return res.json({
      orderId: order.id,
      paymentStatus: order.paymentStatus
    });
  }
);

前端不一定非要轮询,也可以使用 WebSocket 或 Server-Sent Events 接收状态更新。但对普通的支付完成页来说,短时间轮询通常更简单,也足够可靠。

不要使用服务器内存变量

下面这种写法不可靠:

let latestPaymentStatus = "pending";

它会遇到这些问题:

  • 不同用户的状态会互相覆盖。
  • Node.js 进程重启后,数据会消失。
  • 多实例部署时,各实例之间不共享内存。
  • 重复回调和并发更新难以得到可靠处理。
  • 历史事件难以追踪,支付争议也不容易排查。

支付状态应该写入数据库。Redis 可以用于锁、缓存或临时协调,但通常不能代替订单数据库中的最终支付记录。

回调处理必须具备幂等性

如果支付平台没有及时收到成功响应,可能会重复发送同一条回调。遇到网络超时,平台也可能无法确认服务器是否已经处理成功。

因此,同一笔交易无论收到多少次回调,都只能产生一次业务效果。不能重复确认 booking、重复发放权益或重复记录收入。

一种做法是为支付平台的 transaction ID 添加唯一约束:

CREATE UNIQUE INDEX payment_transaction_unique
ON payments(provider_transaction_id);

也可以在数据库事务中执行条件更新:

UPDATE orders
SET payment_status = 'paid',
    paid_at = CURRENT_TIMESTAMP
WHERE id = ?
  AND payment_status <> 'paid';

实际代码还要结合数据库类型、事务隔离级别和业务状态机来设计。无论采用哪种实现,都不能假设支付平台只会调用一次回调接口。

其他重要注意事项

  • callbackUrl 必须是支付平台能够从公网访问的 HTTPS 地址,不能使用 localhost。
  • 开发环境可以通过安全的隧道服务暴露本地端口,但回调仍然需要验证签名。
  • 不要信任前端传入的金额、货币、订单状态或商品价格。
  • 不要把支付密钥写进 React 代码。React 构建产物会发送到浏览器。
  • 回调接口通常不能依赖用户登录 Cookie,因为发起请求的是支付平台。
  • 验证回调签名后,还应该核对 payment ID、金额、货币和本地订单。
  • 如果平台提供支付状态查询 API,可以在关键信息不一致时主动查询确认。
  • 回调接口应该尽快返回 2xx。邮件、出票或通知等耗时任务可以交给队列处理。
  • 日志可以记录 order ID、payment ID 和 transaction ID,但不要写入完整银行卡信息、密钥或其他敏感数据。
  • /payment 无论成功还是失败都必须结束响应。原代码只打印了 Axios 结果,没有调用 res.json()、res.send(),也没有进入错误处理中间件,前端请求可能会一直等待。

整个支付流程可以看作一个异步状态机。/payment 用于发起交易,/payment-callback 接收可信的状态变化,数据库负责关联这两个独立请求,React 只展示后端已经确认的支付状态。

备注:内容仅供参考。