fix(h5-shop): harden iOS WeChat scan after login with hard nav and recover UI
Root cause is JSSDK entry-URL mismatch after SPA post-OAuth, not camera permission. Hard-navigate on iOS, keep OAuth query in sign URL, skip redundant bind OAuth, and prompt refresh/re-auth on failure. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,10 +5,13 @@ export {
|
||||
ensureJssdkReady,
|
||||
isJssdkReady,
|
||||
normalizeJssdkPageUrl,
|
||||
jssdkUrlWithoutHash,
|
||||
getJssdkSignUrl,
|
||||
captureIosJssdkEntryUrl,
|
||||
resetJssdkConfig,
|
||||
stripOAuthParamsFromLocation,
|
||||
hardNavigateInWechat,
|
||||
shouldHardNavigateForJssdk,
|
||||
} from './jssdk';
|
||||
export { formatScanFailMessage, isScanPermissionWarmupError } from './scan';
|
||||
export {
|
||||
|
||||
@@ -11,9 +11,10 @@ let configured = false;
|
||||
let configuredUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* iOS 微信 WebView:JSSDK 签名校验用的是「本次 document 加载」的入场 URL,
|
||||
* iOS 微信 WebView:JSSDK 签名校验用的是「本次 document 加载」的入场 URL(含 query),
|
||||
* SPA pushState/replaceState 后 location.href 会变,但微信仍按入场 URL 验签。
|
||||
* 使用模块级变量:整页刷新(含 OAuth 回跳)会重置;同页 SPA 路由保持不变。
|
||||
* OAuth 回跳带 code/state 时也必须按入场完整 query 签名,不可剔除。
|
||||
* 模块级变量:整页刷新(含 OAuth / location.replace)会重置;同页 SPA 保持不变。
|
||||
*/
|
||||
let iosEntryUrl: string | null = null;
|
||||
|
||||
@@ -23,17 +24,30 @@ export function resetJssdkConfig(): void {
|
||||
configuredUrl = null;
|
||||
}
|
||||
|
||||
/** 参与 JSSDK 签名的页面 URL:与微信文档一致,取 location.href 去掉 # 后的部分;剔除 OAuth 回调参数 */
|
||||
export function normalizeJssdkPageUrl(rawUrl: string): string {
|
||||
/** 仅去 hash,保留全部 query(含 OAuth code/state)— iOS 入场签名必须如此 */
|
||||
export function jssdkUrlWithoutHash(rawUrl: string): string {
|
||||
return rawUrl.split('#')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化签名 URL。
|
||||
* - 默认:去 hash;可保留 code/state(由 keepOAuthQuery 控制)
|
||||
* - Android / 当前页签名:通常已 stripOAuth 后再签,keepOAuthQuery=false
|
||||
*/
|
||||
export function normalizeJssdkPageUrl(rawUrl: string, opts?: { keepOAuthQuery?: boolean }): string {
|
||||
const keepOAuth = !!opts?.keepOAuthQuery;
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
url.hash = '';
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
if (!keepOAuth) {
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
}
|
||||
const query = url.searchParams.toString();
|
||||
return `${url.origin}${url.pathname}${query ? `?${query}` : ''}`;
|
||||
} catch {
|
||||
const noHash = rawUrl.split('#')[0];
|
||||
const noHash = jssdkUrlWithoutHash(rawUrl);
|
||||
if (keepOAuth) return noHash;
|
||||
try {
|
||||
const url = new URL(noHash, typeof window !== 'undefined' ? window.location.origin : 'https://localhost');
|
||||
url.searchParams.delete('code');
|
||||
@@ -59,7 +73,7 @@ function clearLegacySignUrlCache(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获 iOS 微信入场 URL(每个 document 生命周期只记一次)。
|
||||
* 捕获 iOS 微信入场 URL(每个 document 生命周期只记一次;保留 code/state)。
|
||||
* Android / 非微信环境为 no-op。
|
||||
*/
|
||||
export function captureIosJssdkEntryUrl(): void {
|
||||
@@ -67,19 +81,31 @@ export function captureIosJssdkEntryUrl(): void {
|
||||
if (!isIosDevice() || !isWechatBrowser() || isWechatDevTools()) return;
|
||||
clearLegacySignUrlCache();
|
||||
if (iosEntryUrl) return;
|
||||
iosEntryUrl = normalizeJssdkPageUrl(window.location.href);
|
||||
iosEntryUrl = normalizeJssdkPageUrl(window.location.href, { keepOAuthQuery: true });
|
||||
}
|
||||
|
||||
/** 获取参与 JSSDK 签名的 URL;iOS 微信内固定为本次入场 URL */
|
||||
/** 获取参与 JSSDK 签名的 URL;iOS 微信内固定为本次入场 URL(含 OAuth query) */
|
||||
export function getJssdkSignUrl(rawUrl?: string): string {
|
||||
const current = normalizeJssdkPageUrl(
|
||||
rawUrl ?? (typeof window !== 'undefined' ? window.location.href : ''),
|
||||
);
|
||||
if (typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools()) {
|
||||
captureIosJssdkEntryUrl();
|
||||
if (iosEntryUrl) return iosEntryUrl;
|
||||
}
|
||||
return current;
|
||||
return normalizeJssdkPageUrl(rawUrl ?? (typeof window !== 'undefined' ? window.location.href : ''), {
|
||||
keepOAuthQuery: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS 微信内业务跳转须用整页导航,使下一页成为新的 JSSDK 入场 URL。
|
||||
* SPA navigate 会导致扫码/定位等 JSAPI 验签失败。
|
||||
*/
|
||||
export function hardNavigateInWechat(path: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.location.replace(path);
|
||||
}
|
||||
|
||||
export function shouldHardNavigateForJssdk(): boolean {
|
||||
return typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools();
|
||||
}
|
||||
|
||||
function isJssdkDebugEnabled(): boolean {
|
||||
@@ -95,7 +121,7 @@ export function stripOAuthParamsFromLocation(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const url = new URL(window.location.href);
|
||||
if (!url.searchParams.has('code') && !url.searchParams.has('state')) return;
|
||||
// iOS:须先锁定入场 URL,再 replaceState;否则签名 URL 与微信内部 URL 不一致
|
||||
// iOS:须先锁定入场 URL(含 code/state),再 replaceState
|
||||
captureIosJssdkEntryUrl();
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
@@ -147,7 +173,6 @@ export async function initWechatJssdk(options: {
|
||||
}): Promise<void> {
|
||||
captureIosJssdkEntryUrl();
|
||||
const { apiBase, clientApp, getAccessToken } = options;
|
||||
// iOS 忽略调用方传入的「当前页」URL,强制入场 URL,避免登录后 SPA 到首页签错名
|
||||
const pageUrl = getJssdkSignUrl(options.url);
|
||||
await loadScript();
|
||||
if (!window.wx) throw new Error('微信 JSSDK 不可用');
|
||||
|
||||
Reference in New Issue
Block a user