支付宝支付首次正常,二次拉起在 Safari 报网址无效
结论
第二次无法拉起支付,通常不是因为”支付宝只能调用一次”,而是页面重复使用了第一次生成的跳转地址、表单或临时状态。也可能是用户返回页面后,页面仍被锁定在”正在支付”状态。
Safari 对格式错误、重复编码或已经失效的自定义协议地址更敏感,遇到这类地址时可能直接提示”网址无效”。Android 上没有反应,则常见于点击事件被拦截、页面状态没有恢复,或 WebView 没有处理外部协议。
用户每次点击支付时,都应先向服务端查询订单状态。如果订单尚未支付,由服务端重新生成支付请求,前端再通过用户的真实点击事件立即提交新表单或跳转到新地址。不要缓存或重复使用第一次生成的 alipays://、intent://、支付表单 HTML,也不要反复编解码支付 URL。
优先检查的问题
1. 是否重复使用了第一次的支付地址
如果页面初始化时只请求一次支付参数,后续点击始终使用同一个变量,第二次调用就可能异常:
let cachedPayUrl = '';
async function initPay() {
cachedPayUrl = await requestPayUrl();
}
function pay() {
window.location.href = cachedPayUrl;
}
支付地址中可能包含一次性参数、签名或编码后的回跳信息,不能默认长期有效。每次点击支付时,都应让服务端重新构造支付请求。
2. URL 是否被重复编码或解码
Safari 提示”网址无效”,经常是因为最终地址的格式已经损坏。例如:
// 错误:接口返回的地址已经编码,又被重复编码
location.href = encodeURIComponent(payUrl);
或者:
// 错误:整个支付 URL 被 decodeURIComponent,破坏了查询参数
location.href = decodeURIComponent(payUrl);
如果服务端返回的是完整 URL,前端通常可以直接使用:
window.location.assign(payUrl);
构造普通 HTTPS 查询参数时,只编码参数值:
const returnUrl = 'https://example.com/pay/return?source=h5';
const url =
'/api/pay/create?return_url=' + encodeURIComponent(returnUrl);
不要对包含 ://、?、&、= 的完整 URL 再执行 encodeURIComponent()。
3. 返回页面后,按钮状态是否没有恢复
不少页面会用变量阻止重复提交:
let paying = false;
async function startPay() {
if (paying) return;
paying = true;
const result = await createPayment();
window.location.href = result.payUrl;
}
用户从支付宝返回时,浏览器可能通过前进后退缓存恢复原页面,JavaScript 上下文也可能继续保留。此时 paying 仍是 true,Android 上再次点击便会直接返回,看起来像是”没有反应”。
可以监听 pageshow,在页面恢复时重置状态:
let paying = false;
const payButton = document.querySelector('#pay-button');
function resetPayState() {
paying = false;
payButton.disabled = false;
payButton.textContent = '立即支付';
}
window.addEventListener('pageshow', (event) => {
// event.persisted 为 true 时,页面通常来自前进后退缓存
resetPayState();
refreshOrderStatus();
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
resetPayState();
refreshOrderStatus();
}
});
这里监听 pageshow 比只监听 load 更合适,因为从外部应用返回时,页面不一定会重新执行完整加载。
推荐的支付流程
订单状态、支付参数和签名由后端处理,前端只负责触发支付:
用户点击支付
↓
前端请求后端查询订单
↓
已支付 → 展示支付成功
未支付 → 后端重新生成支付宝支付请求
↓
前端立即提交新表单或跳转到新的 HTTPS 地址
↓
从支付宝返回后重新查询订单状态
不要只根据前端回跳参数判断支付结果。最终状态应以服务端收到的异步通知,或服务端主动查询到的支付宝交易状态为准。
前端实现示例
如果后端返回支付宝 WAP 支付表单 HTML,可以在用户每次点击时重新获取并提交:
<button id="pay-button" type="button">立即支付</button>
<div id="pay-form-container"></div>
<script>
let paying = false;
const payButton = document.querySelector('#pay-button');
const container = document.querySelector('#pay-form-container');
payButton.addEventListener('click', startPay);
async function startPay() {
if (paying) return;
paying = true;
payButton.disabled = true;
payButton.textContent = '正在拉起支付…';
try {
const response = await fetch('/api/alipay/wap-pay', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
orderId: 'YOUR_ORDER_ID'
})
});
if (!response.ok) {
throw new Error(`创建支付请求失败:HTTP ${response.status}`);
}
const result = await response.json();
if (result.paid) {
window.location.replace('/pay/success');
return;
}
if (!result.formHtml) {
throw new Error('服务端未返回支付表单');
}
// 每次清除旧表单,避免提交上一次的参数。
container.replaceChildren();
container.innerHTML = result.formHtml;
const form = container.querySelector('form');
if (!form) {
throw new Error('支付表单格式不正确');
}
form.submit();
} catch (error) {
console.error(error);
paying = false;
payButton.disabled = false;
payButton.textContent = '重新支付';
}
}
window.addEventListener('pageshow', () => {
paying = false;
payButton.disabled = false;
payButton.textContent = '立即支付';
checkOrderStatus();
});
async function checkOrderStatus() {
try {
const response = await fetch(
'/api/orders/YOUR_ORDER_ID/status',
{ cache: 'no-store' }
);
const result = await response.json();
if (result.paid) {
window.location.replace('/pay/success');
}
} catch (error) {
console.error('查询订单状态失败', error);
}
}
</script>
如果后端返回可跳转的 HTTPS 支付地址:
async function startPay() {
if (paying) return;
paying = true;
try {
const response = await fetch('/api/alipay/pay-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ orderId })
});
const result = await response.json();
if (!result.payUrl || !/^https:\/\//i.test(result.payUrl)) {
throw new Error('支付地址无效');
}
window.location.assign(result.payUrl);
} catch (error) {
paying = false;
console.error(error);
}
}
除非接入方案明确要求,否则不要在业务页面中自行拼接 alipays:// 或 intent:// 地址。
服务端需要处理的逻辑
服务端创建支付请求前,应先检查业务订单:
async function createAlipayPayment(orderId) {
const order = await orderRepository.findById(orderId);
if (!order) {
throw new Error('订单不存在');
}
if (order.status === 'PAID') {
return { paid: true };
}
if (order.status === 'CLOSED') {
throw new Error('订单已关闭');
}
// 使用服务端保存的订单金额和标题,不信任前端传入的数据。
const formHtml = await alipayClient.pageExecute(
'alipay.trade.wap.pay',
{
bizContent: {
out_trade_no: order.tradeNo,
total_amount: order.amount,
subject: order.subject,
product_code: 'QUICK_WAP_WAY'
},
returnUrl: 'https://example.com/pay/return',
notifyUrl: 'https://example.com/api/alipay/notify'
},
'POST'
);
return {
paid: false,
formHtml
};
}
不同语言和 SDK 版本的方法签名、参数结构可能不同,具体写法应以当前使用的支付宝 SDK 文档为准。服务端需要重新执行支付请求的生成流程,不能把第一次生成的结果长期缓存在前端。
同一个 out_trade_no 能否再次发起支付,以及哪些业务字段必须保持一致,应根据实际接口返回结果处理。不要在未确认订单状态时,为每次点击随意创建新的业务订单号,否则可能产生重复订单,也会让支付状态难以对应。
Safari 专项排查
在 Safari 中复现问题时,可以记录第二次点击前的相关状态:
console.log({
paying,
visibilityState: document.visibilityState,
payUrl,
payUrlLength: payUrl?.length
});
检查以下内容:
- 地址是否为空、
undefined或[object Object]。 - 地址前后是否带有空格、换行或额外引号。
https://是否被错误编码成https%3A%2F%2F。- 自定义协议中的
%是否构成了不完整的转义序列。 - 第二次使用的地址是否与第一次完全相同。
- 跳转是否由用户直接点击触发,而不是发生在延迟回调、定时器或页面自动跳转中。
- 返回页面时是否命中了前进后退缓存。
如果必须使用外部 App 协议,调用最好直接发生在点击处理函数中。Safari 可能会限制异步操作完成后的自定义协议跳转:
// 可能被浏览器认为不是直接由用户操作触发
button.addEventListener('click', async () => {
const schemeUrl = await fetchSchemeUrl();
window.location.href = schemeUrl;
});
更稳妥的做法是先进入支付宝提供的 HTTPS 收银台,由收银台处理 App 拉起和降级流程。
Android 专项排查
Android 上点击没有反应时,先确认点击事件是否真的执行了:
button.addEventListener('click', () => {
console.log('pay button clicked');
});
同时检查:
- 按钮是否仍有
disabled属性。 - 页面上是否有透明遮罩层拦截点击。
paying、防抖或节流逻辑是否直接执行了return。- Promise 是否一直没有结束。
- 页面是否运行在 App 内置 WebView 中。
- WebView 是否允许打开外部应用协议。
如果页面嵌在自有 Android WebView 中,只修改 H5 可能无法解决问题。宿主应用通常还要在 URL 拦截回调中识别外部协议,再通过系统 Intent 打开支付宝。具体实现取决于 WebView 容器和 Android 版本。测试时应区分普通 Chrome 和自有 WebView。
注意事项
- 不要直接缓存支付表单、签名结果或自定义协议地址。
- 用户返回支付页后,应重新查询订单状态并恢复按钮。
- 防重复点击锁需要在支付失败、取消和返回页面等路径中解除。
- 前端回跳不代表支付成功,最终结果以服务端通知或交易查询为准。
- 金额、订单号和商品信息应由服务端读取,不能信任前端提交的值。
- 分别测试 Safari、Chrome、微信内置浏览器和自有 WebView。它们对外部协议的限制并不完全一致。
- 调试日志中不要完整输出签名、用户标识或敏感支付参数。
备注:内容仅供参考。
