PAYATHON 2026

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 只用于辅助恢复页面信息。

备注:内容仅供参考。