如何从 callback URL 的响应中提取支付数据?
结论
支付回调应该由 Express 提供独立的服务端接口接收,例如:
POST https://example.com/payment-callback
不要让 /payment 请求一直等待回调。这是两个互相独立的 HTTP 请求,可能由不同进程处理,间隔也可能达到几分钟甚至更久。
React Router 适合处理用户浏览器访问的 completeUrl、cancelUrl 等页面,不适合接收支付平台发来的服务器回调。支付结果应该写入数据库,不能暂存在服务器内存变量中。
完整流程可以这样设计:
- 前端调用后端
/payment创建支付。 - 后端保存本地订单和支付平台的 payment ID。
- 后端立即把支付跳转地址或本地订单号返回给前端。
- 用户在支付页面完成付款。
- 支付平台向
/payment-callback发送 POST 请求。 - 后端验证回调、更新数据库,并立即返回成功响应。
- 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 只展示后端已经确认的支付状态。
备注:内容仅供参考。
