Node.js + Express 支付跳转 returnUrl 延迟约 5 秒
结论
notifyUrl 很快、returnUrl 晚约 5 秒,通常不是 Node.js、Express 或支付宝 SDK 处理缓慢造成的。
两者走的是不同链路:
notifyUrl是支付宝服务器直接请求商户服务器的异步通知地址。returnUrl是支付完成后,支付宝页面通过用户浏览器跳转到商户页面的返回地址。
returnUrl 的速度会受到支付宝收银台停留时间、浏览器跳转、网络、DNS、TLS、重定向链和目标页面资源加载等因素影响。几秒钟的延迟可能发生在客户端跳转过程中,不能因为 notifyUrl 很快,就认为 returnUrl 也应该同时到达。
订单处理应以验签成功的异步通知或主动查询结果为准,不要依赖 returnUrl。
先判断延迟发生在哪一段
排查的第一步,是确认浏览器何时开始请求 returnUrl。
可以在 Express 的返回地址中记录精确时间:
import express from 'express';
const app = express();
app.get('/payment/return', (req, res) => {
const receivedAt = new Date();
console.log('returnUrl received:', {
receivedAt: receivedAt.toISOString(),
query: req.query,
userAgent: req.get('user-agent')
});
res.redirect(302, '/success.html');
});
app.listen(3000);
然后打开浏览器开发者工具,在 Network 面板中检查:
- 支付完成后,
/payment/return请求是什么时候发出的。 - 请求的
Waiting (TTFB)是否过长。 - 中间是否经过多次
301、302或307跳转。 success.html及其 JavaScript、CSS、图片是否加载缓慢。
根据结果,可以分成两种情况。
Express 很晚才收到请求
如果支付完成约 5 秒后,Express 才记录到 /payment/return,说明延迟出现在支付宝页面、客户端浏览器或中间网络链路中。
这种延迟通常无法通过调整 Express 路由解决,需要继续检查:
return_url是否为公网可访问的 HTTPS 地址。- 域名是否存在 DNS 解析缓慢或 IPv6 配置异常。
- HTTPS 证书链是否完整。
- 请求是否经过 CDN、WAF、反向代理或多层跳转。
- 支付过程是否发生在微信、App WebView 等内置浏览器中。
- 支付完成页面是否要先执行客户端逻辑,然后才发起跳转。
return_url是否先从 HTTP 跳转到 HTTPS,或从一个域名再次跳转到另一个域名。
Express 很快收到请求,但页面很晚才显示
如果 Express 立即收到了请求,但 success.html 迟迟没有显示,问题就在商户自己的返回接口或前端页面中。
可以重点检查:
- 返回接口是否查询数据库或调用第三方服务。
- 是否要等订单状态更新后才跳转。
- 是否存在慢查询、连接池不足或超时重试。
success.html是否引用了加载缓慢的外部资源。- 页面是否要等大体积 JavaScript 执行完才开始渲染。
- 是否存在登录校验、Session 查询或连续重定向。
- 反向代理是否缓冲了响应。
正确处理支付结果
支付流程应拆成两条互不依赖的链路:
支付宝服务器 -> notifyUrl -> 验签并更新订单
用户浏览器 -> returnUrl -> 展示支付结果页
notifyUrl 用于可靠地处理订单状态,returnUrl 只负责页面跳转和结果展示。即使用户关闭浏览器、网络中断,或者没有返回商户页面,订单也应该能通过异步通知完成处理。
示例:
import express from 'express';
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.post('/payment/notify', async (req, res) => {
try {
// 具体验签方式取决于使用的支付宝 SDK。
const verified = await verifyAlipayNotification(req.body);
if (!verified) {
return res.status(400).send('failure');
}
const {
out_trade_no: orderNo,
trade_no: tradeNo,
trade_status: tradeStatus,
total_amount: totalAmount
} = req.body;
// 还应核对订单号、金额、收款方身份等业务数据。
await updateOrderIdempotently({
orderNo,
tradeNo,
tradeStatus,
totalAmount
});
return res.type('text/plain').send('success');
} catch (error) {
console.error('payment notify failed:', error);
return res.status(500).type('text/plain').send('failure');
}
});
app.get('/payment/return', async (req, res) => {
// 可进行同步返回参数验签,但不要仅凭这些参数修改订单状态。
const orderNo = req.query.out_trade_no;
res.redirect(
302,
`/success.html?orderNo=${encodeURIComponent(String(orderNo || ''))}`
);
});
verifyAlipayNotification 和 updateOrderIdempotently 是业务占位函数,需要根据项目使用的 SDK 和数据库自行实现。
避免在 returnUrl 中等待订单状态
一种常见做法是,浏览器到达 returnUrl 后,服务端循环查询数据库,直到 notifyUrl 把订单更新为已支付:
app.get('/payment/return', async (req, res) => {
// 不建议:可能直接造成数秒延迟
await waitUntilOrderPaid(req.query.out_trade_no);
res.redirect('/success.html');
});
不建议这样处理。同步跳转和异步通知没有固定的到达顺序,returnUrl 可能先到,也可能后到。阻塞等待会直接延长页面响应时间;如果异步通知丢失或处理失败,还可能导致请求超时。
更合适的方式是立即打开结果页,再让前端短时间轮询商户服务器中的订单状态:
app.get('/api/orders/:orderNo/status', async (req, res) => {
const order = await findOrder(req.params.orderNo);
if (!order) {
return res.status(404).json({ status: 'NOT_FOUND' });
}
res.json({
status: order.status
});
});
<script>
const params = new URLSearchParams(location.search);
const orderNo = params.get('orderNo');
async function refreshOrderStatus() {
const response = await fetch(
`/api/orders/${encodeURIComponent(orderNo)}/status`,
{ cache: 'no-store' }
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (result.status === 'PAID') {
document.querySelector('#status').textContent = '支付成功';
return;
}
document.querySelector('#status').textContent = '支付结果确认中';
setTimeout(refreshOrderStatus, 1000);
}
refreshOrderStatus().catch(() => {
document.querySelector('#status').textContent = '暂时无法获取支付结果';
});
</script>
轮询需要限制次数或总时长,不能无限请求。超过等待时间后,可以提示用户稍后查看订单,也可以由后端调用支付宝交易查询接口进行补偿确认。
检查支付跳转 form 的提交方式
alipay.trade.wap.pay 返回的 form 通常需要在浏览器中提交。后端可以直接把 form 返回给前端,也可以由前端插入页面后立即提交,避免不必要的等待。
服务端直接响应示例:
app.post('/payment/create', async (req, res) => {
const form = await createAlipayWapPayForm({
outTradeNo: req.body.orderNo,
totalAmount: req.body.amount,
subject: req.body.subject,
returnUrl: 'https://pay.example.com/payment/return',
notifyUrl: 'https://pay.example.com/payment/notify'
});
res.type('html').send(form);
});
如果前端通过 AJAX 获取 form,要确保其中的脚本能够正确执行。仅用 innerHTML 插入包含 <script> 的字符串时,脚本不一定会按预期运行。更稳妥的做法是获取 form 后主动提交:
const response = await fetch('/payment/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(paymentData)
});
const html = await response.text();
const container = document.createElement('div');
container.innerHTML = html;
document.body.appendChild(container);
const form = container.querySelector('form');
if (!form) {
throw new Error('支付宝跳转表单不存在');
}
form.submit();
另一种方式是让普通 HTML form 直接提交到商户的支付创建接口,再由服务端返回支付宝 form。这样可以减少 AJAX 和动态脚本带来的兼容性问题。
优化 returnUrl 和成功页
返回接口只需完成必要工作:
- 验证参数签名。
- 提取商户订单号。
- 快速返回结果页,或只执行一次跳转。
- 不在请求中发送邮件、生成报表或调用非必要的第三方接口。
- 不等待异步通知处理完成。
如果还需要定位服务端耗时,可以添加响应时间日志:
app.use((req, res, next) => {
const start = process.hrtime.bigint();
res.on('finish', () => {
const elapsedMs =
Number(process.hrtime.bigint() - start) / 1_000_000;
console.log({
method: req.method,
path: req.originalUrl,
status: res.statusCode,
elapsedMs: elapsedMs.toFixed(2)
});
});
next();
});
还可以用下面的命令检查返回地址的 DNS、连接、TLS 和首字节耗时:
curl -L -o /dev/null -s \
-w 'dns=%{time_namelookup}\nconnect=%{time_connect}\ntls=%{time_appconnect}\nttfb=%{time_starttransfer}\ntotal=%{time_total}\n' \
'https://pay.example.com/payment/return'
该测试只能测量测试机器到商户地址的链路,无法完全模拟支付宝 App、手机浏览器或 WebView 中的实际跳转,但可以帮助发现商户侧较明显的问题。
安全和业务注意事项
- 不要因为浏览器进入
returnUrl就把订单标记为支付成功。 - 对
notifyUrl的参数执行支付宝签名验证。 - 即使验签成功,也要核对
out_trade_no、total_amount、应用或收款方身份等业务数据。 - 异步通知处理必须保证幂等,避免重复通知引起重复发货或重复记账。
- 按实际 SDK 的要求返回成功响应,避免支付宝持续重试通知。
- 前端显示“支付成功”前,应读取商户服务器保存的订单状态。
- 如果异步通知长时间没有到达,可以调用支付宝交易查询接口进行补偿确认,但不要高频查询。
- 日志中不要记录私钥、完整签名材料或不必要的用户敏感信息。
如果 Express 在支付完成约 5 秒后才收到第一个 returnUrl 请求,应重点检查跳转链路、网络和运行环境。如果 Express 立即收到请求,则继续排查服务端响应时间、重定向次数和成功页资源加载。无论延迟发生在哪里,订单状态都不应依赖这次同步跳转。
备注:内容仅供参考。
