H5支付在Safari返回时打开新页签导致会话缓存丢失
结论
这是 Safari 与支付宝 App 通过 URL 唤起和回跳时的正常行为。支付完成后,“完成”按钮通常会打开商户配置的回跳地址,但不保证回到原来的 Safari 页签。
如果业务状态保存在 sessionStorage、页面内存、Vuex/Redux 内存状态或原页面的 JavaScript 上下文中,新页签无法访问这些数据,看起来就像”会话缓存丢失”。因此,支付结果和重要业务数据不能依赖原页签保存,必须以服务端的订单状态为准。
为什么会打开新页签
H5 支付通常经过以下过程:
- 商户页面在 Safari 中创建支付订单。
- 页面跳转或唤起支付宝 App。
- 用户完成支付。
- 支付宝按照支付请求中的回跳地址返回浏览器。
- iOS/Safari 根据当时的浏览器状态、唤起方式和系统调度打开页面。
从支付宝 App 返回 Safari 是一次跨应用导航。回跳的本质是重新打开一个 URL,支付宝无法可靠地指定 Safari 中某个已有的页签。实际可能出现以下几种情况:
- 回到原页签;
- 在当前页签加载回跳地址;
- 新建一个 Safari 页签;
- Safari 先恢复之前的页面,再打开回跳地址。
具体表现会受到 iOS、Safari、支付宝版本和唤起方式的影响,业务流程不能建立在”始终返回原页签”这个前提上。
哪些状态会丢失
最容易受影响的是 sessionStorage。它通常按浏览器页签或顶层浏览上下文隔离。即使新页签打开的是同一域名,也不能假定它可以读取原页签的 sessionStorage。
以下数据也可能无法恢复:
- JavaScript 变量;
- Vuex、Redux 等未持久化状态;
- 尚未写入存储的表单内容;
- 与原页面实例绑定的定时器、回调和事件状态。
localStorage 和第一方 Cookie 通常可以在同源页签之间共享,但它们仍可能受隐私模式、Safari 存储策略、Cookie 属性和跨域跳转方式影响,也不能替代服务端保存的支付状态。
推荐处理方案
1. 支付前在服务端创建业务订单
先让服务端生成不可重复的业务订单号,并保存订单与当前用户的关系。前端发起支付时,只携带必要的订单标识。
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
productId: 'product_123',
quantity: 1
})
});
const { orderId, paymentUrl } = await response.json();
localStorage.setItem('pendingPaymentOrderId', orderId);
window.location.assign(paymentUrl);
localStorage 中的订单号只用于改善页面恢复体验,不能作为支付成功的可信凭证。
2. 在回跳地址中携带可定位订单的参数
回跳地址需要能够定位业务订单,例如:
https://pay.example.com/payment/return?orderId=ORDER_202609090001
如果不适合直接暴露订单号,可以改用服务端生成的短期随机状态值:
https://pay.example.com/payment/return?state=RANDOM_OPAQUE_TOKEN
state 需要与用户、订单和有效期绑定,并有足够的随机性。其中不能包含客户端篡改后就会被直接信任的支付结果。
3. 回跳页面主动查询服务端订单状态
回跳参数只负责定位订单。即使 URL 中带有 success=true,页面也不能据此认定支付成功,而应查询服务端已经确认的订单状态。
async function restorePaymentResult() {
const params = new URLSearchParams(window.location.search);
const state = params.get('state');
if (!state) {
showResult('无法识别订单,请前往订单列表查看');
return;
}
try {
const response = await fetch(
`/api/payments/status?state=${encodeURIComponent(state)}`,
{
credentials: 'include',
cache: 'no-store'
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
switch (result.status) {
case 'PAID':
showResult('支付成功');
break;
case 'PENDING':
showResult('支付结果确认中,请稍后刷新');
break;
case 'CLOSED':
showResult('订单已关闭');
break;
default:
showResult('暂未获取到支付结果');
}
} catch (error) {
showResult('订单状态查询失败,请前往订单列表确认');
}
}
如果支付宝的异步通知还未到达,接口可以返回 PENDING,前端再进行有限次数的轮询:
async function waitForPayment(orderId) {
const maxAttempts = 10;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const response = await fetch(
`/api/orders/${encodeURIComponent(orderId)}/payment-status`,
{
credentials: 'include',
cache: 'no-store'
}
);
const result = await response.json();
if (result.status === 'PAID' || result.status === 'CLOSED') {
return result;
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
return { status: 'PENDING' };
}
轮询间隔和次数需要根据实际业务设置,不能无限轮询。
4. 以异步通知为支付成功依据
支付平台的前端回跳只说明用户回到了商户页面,并不代表支付一定成功。服务端需要验证支付宝异步通知的签名,再更新订单状态:
app.post('/api/alipay/notify', async (req, res) => {
const verified = verifyAlipayNotification(req.body);
if (!verified) {
res.status(400).send('failure');
return;
}
const order = await findOrder(req.body.out_trade_no);
if (!order) {
res.status(404).send('failure');
return;
}
if (
req.body.trade_status === 'TRADE_SUCCESS' ||
req.body.trade_status === 'TRADE_FINISHED'
) {
await markOrderPaidIdempotently(order.id, req.body.trade_no);
}
res.send('success');
});
verifyAlipayNotification 应按照所接入支付宝产品的官方 SDK 和验签规则实现,不能自行拼接字符串判断签名。
5. 将非敏感页面状态改为可恢复状态
购物步骤、展示偏好等非敏感数据可以暂存在 localStorage,也可以直接保存到服务端:
const recoverableState = {
orderId,
returnPath: '/checkout/result',
savedAt: Date.now()
};
localStorage.setItem(
'paymentRecovery',
JSON.stringify(recoverableState)
);
回跳后读取这些数据,并及时清理:
const raw = localStorage.getItem('paymentRecovery');
if (raw) {
const recovery = JSON.parse(raw);
localStorage.removeItem('paymentRecovery');
if (Date.now() - recovery.savedAt < 30 * 60 * 1000) {
restoreOrder(recovery.orderId);
}
}
不要在 Web Storage 中保存登录凭证、支付密钥、完整身份信息,也不要保存任何可以直接改变订单状态的数据。
能否自动关闭新页签并返回原页签
一般无法可靠实现。只有窗口由 JavaScript window.open() 创建,并且仍能访问 window.opener 时,才可以尝试:
if (window.opener && !window.opener.closed) {
window.opener.postMessage(
{ type: 'PAYMENT_RETURNED' },
'https://pay.example.com'
);
window.close();
}
支付宝 App 回跳产生的 Safari 页签通常没有可用的 window.opener,浏览器也可能阻止脚本关闭并非由脚本打开的页面。因此,这种方式只能用来辅助改善体验,不能作为主流程。
同源页签之间还可以通过 BroadcastChannel 通知仍在运行的原页面:
const channel = new BroadcastChannel('payment-status');
channel.postMessage({
type: 'PAYMENT_RETURNED',
orderId
});
原页面接收消息:
const channel = new BroadcastChannel('payment-status');
channel.addEventListener('message', event => {
if (event.data?.type === 'PAYMENT_RETURNED') {
refreshOrderStatus(event.data.orderId);
}
});
这种方式同样依赖原页签仍在运行,而且浏览器需要支持 BroadcastChannel,所以也只适合用来改善体验。
注意事项
- 不要依赖
sessionStorage跨越支付宝 App 跳转。 - 不要把前端回跳参数当作支付成功的证明。
- 服务端必须验证异步通知签名、商户身份、订单号、金额和币种等重要字段。
- 更新订单时需要保证幂等,避免重复通知引发重复发货或重复记账。
- 登录 Cookie 应根据实际跳转链路正确设置
Secure、HttpOnly和SameSite。如果回跳后登录状态丢失,需要单独排查 Cookie,不能直接归因于新页签。 - 回跳页面需要支持
PENDING状态,并向用户提供订单列表或手动刷新入口。 - 支付前尚未提交的数据,应提前保存到服务端或可恢复的存储中,不能等支付完成后再依赖原页面提交。
处理这类流程时,应把支付宝回跳视为一次可能发生在全新浏览器上下文中的页面访问。只要订单和业务流程能够根据服务端状态恢复,Safari 即使新开页签,也不会影响支付结果确认和后续处理。
备注:内容仅供参考。
