iOS App 中 H5 支付返回后 localstorage 订单ID异常
结论
这通常不是 localStorage.setItem() 写入失败,也不是普通的 HTTP 缓存问题。
如果写入订单号 B 后,同一页面立即读取到的确实是 B,但支付成功返回后却稳定变成 A,那么返回时进入的很可能不是刚才写入 B 的页面上下文。常见原因有:
- 支付平台沿用了第一次支付请求中的
return_url,因此返回订单 A 对应的旧页面。 - iOS 恢复了历史记录或 Back-Forward Cache 中的旧页面。
- App 创建了多个
WKWebView,各自使用不同的WKWebsiteDataStore或页面实例。 - 支付前后的页面 origin 不一致,例如协议、域名、子域名或端口发生了变化。
- 返回页面显示的 A 来自首次加载时保存的 JavaScript 变量、URL 参数或框架状态,而不是刚刚重新读取的
localStorage。
不要把 localStorage 中的订单号和支付状态作为最终依据。更可靠的做法是为每次支付创建唯一的支付尝试标识,返回后根据 URL 中的标识向服务端查询订单状态。
先确认实际读取环境
分别在写入订单号时和支付返回后记录这些信息:
function printPaymentContext(stage) {
console.log(stage, {
href: location.href,
origin: location.origin,
orderId: localStorage.getItem('orderId'),
timestamp: Date.now()
});
}
localStorage.setItem('orderId', orderIdB);
printPaymentContext('before-payment');
window.addEventListener('pageshow', (event) => {
console.log('pageshow.persisted:', event.persisted);
printPaymentContext('after-return');
});
重点检查:
location.origin是否完全相同。- 返回 URL 是否仍然带有订单 A 的参数。
pageshow.persisted是否为true。- 返回后的值究竟是从
localStorage读取的,还是来自某个初始化变量。 - 原生端是否重新创建或切换了
WKWebView。
localStorage 按 origin 隔离,下面这些地址使用的不是同一个存储空间:
http://pay.example.com
https://pay.example.com
https://www.example.com
https://pay.example.com:8443
不要复用第一次支付的返回地址
每次支付都要生成新的 return_url,并在其中携带本次支付尝试的唯一标识:
const paymentAttemptId = crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const state = {
orderId: currentOrderId,
paymentAttemptId,
updatedAt: Date.now()
};
localStorage.setItem('currentPayment', JSON.stringify(state));
const returnUrl = new URL('/payment/result', location.origin);
returnUrl.searchParams.set('paymentAttemptId', paymentAttemptId);
// 将 returnUrl.toString() 传给服务端,由服务端创建本次支付请求
服务端需要保存以下对应关系:
paymentAttemptId -> orderId -> 支付平台交易号 -> 支付状态
支付成功返回后,应使用 paymentAttemptId 查询服务端,不能只根据本地订单号判断:
async function refreshPaymentResult() {
const params = new URLSearchParams(location.search);
const paymentAttemptId = params.get('paymentAttemptId');
if (!paymentAttemptId) {
throw new Error('Missing paymentAttemptId');
}
const response = await fetch(
`/api/payment/status?paymentAttemptId=${encodeURIComponent(paymentAttemptId)}`,
{
cache: 'no-store',
credentials: 'include'
}
);
if (!response.ok) {
throw new Error(`Payment status request failed: ${response.status}`);
}
const result = await response.json();
// orderId 和支付状态以服务端结果为准
console.log(result.orderId, result.status);
}
window.addEventListener('pageshow', refreshPaymentResult);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
refreshPaymentResult();
}
});
支付平台的同步返回只用于恢复页面并触发查询,不能作为支付成功的最终凭证。最终状态要以服务端收到的异步通知,或服务端主动查询支付平台所得的结果为准。
检查 WKWebView 配置
如果 App 使用 WKWebView,应尽量复用同一个实例,并采用持久化数据存储:
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = WKWebsiteDataStore.default()
let webView = WKWebView(
frame: .zero,
configuration: configuration
)
不同支付步骤之间不要混用 .default() 和 .nonPersistent():
// 无痕数据存储,页面关闭后不能作为可靠的持久存储使用
configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent()
同时检查原生返回逻辑是否做了以下操作:
- 加载首次保存的支付页面 URL。
- 从导航栈返回订单 A 的旧页面。
- 新建另一个
WKWebView。 - 支付完成后恢复旧的页面快照。
- 使用第一次支付时缓存的
return_url。
较旧的 iOS/WebKit 环境可能更容易暴露这类页面恢复问题,但不能仅凭 iOS 12.5.1 就认定是系统缺陷。需要结合 URL、origin、pageshow.persisted 和 WKWebView 实例日志进行确认。
注意事项
清除浏览器缓存或给静态资源添加随机参数,最多只能暂时掩盖问题,无法解决返回地址错误、页面上下文变化或多个 WKWebView 之间的数据隔离。
localStorage.setItem() 在当前 JavaScript 上下文中是同步的,可以立即校验写入结果:
localStorage.setItem('orderId', currentOrderId);
if (localStorage.getItem('orderId') !== currentOrderId) {
throw new Error('orderId was not stored in the current browsing context');
}
这项校验只能证明当前页面写入成功,不能证明支付返回后进入的仍是同一个页面实例和 origin。
更稳妥的方案是为每次支付生成独立的 paymentAttemptId,确保 return_url 不会复用旧参数,并在返回后重新向服务端确认订单。localStorage 只用于辅助恢复页面信息。
备注:内容仅供参考。
