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:
2026-08-07 13:08:13 +08:00
parent 2a9493165b
commit e4e9eb2169
12 changed files with 192 additions and 47 deletions
+34
View File
@@ -0,0 +1,34 @@
# @dukang/weixin-sdk · 踩坑
## iOS 微信 H5JSSDK 入场 URL(扫码 / 定位 / 选图)
### 症状
- 登录或 OAuth 后立刻调 `scanQRCode` / `getLocation` / `chooseImage` 失败
- 错误类似:`permission value is offline verifying``invalid signature`
- 业务文案常被写成「权限校验尚未完成,请等 1~2 秒」——**多数情况下等无效**
- 关掉 webview 再进(整页重载)立即恢复
### 原因
iOS 微信对 JS-SDK 验签使用的是**本次 document 加载时的 URL**(去掉 `#` 后的完整 URL**含 query**)。
`history.pushState` / `replaceState`(含 React Router)**不会**更新微信内部用于验签的 URL。
典型错误链路:
1. OAuth 回跳:`/login?code=xxx&state=yyy`(入场 URL
2. SPA`navigate('/')`
3. 用当前页 `/` 或「去掉 code 后的 `/login`」去签名 → 与微信内部 URL 不一致 → 失败
### 正确做法
1. **业务跳转(登录成功 / 选店进首页)**iOS 微信内用 `hardNavigateInWechat(path)` / `location.replace`,让目标页成为新的入场 URL。
2. **签名 URL**`getJssdkSignUrl()` 在 iOS 上返回入场 URLOAuth 的 `code/state` **必须保留**参与签名;后端 `jssdk-config` 只去 `#`,不要删 query。
3. **先 `captureIosJssdkEntryUrl()`,再 `stripOAuthParamsFromLocation()`**
4. 失败恢复:引导用户刷新页面或重新走 OAuth,而不是无限「再点一次」。
### 相关 API
- `captureIosJssdkEntryUrl` / `getJssdkSignUrl`
- `shouldHardNavigateForJssdk` / `hardNavigateInWechat`
- `stripOAuthParamsFromLocation`
+3
View File
@@ -5,10 +5,13 @@ export {
ensureJssdkReady,
isJssdkReady,
normalizeJssdkPageUrl,
jssdkUrlWithoutHash,
getJssdkSignUrl,
captureIosJssdkEntryUrl,
resetJssdkConfig,
stripOAuthParamsFromLocation,
hardNavigateInWechat,
shouldHardNavigateForJssdk,
} from './jssdk';
export { formatScanFailMessage, isScanPermissionWarmupError } from './scan';
export {
+41 -16
View File
@@ -11,9 +11,10 @@ let configured = false;
let configuredUrl: string | null = null;
/**
* iOS 微信 WebViewJSSDK 签名校验用的是「本次 document 加载」的入场 URL
* iOS 微信 WebViewJSSDK 签名校验用的是「本次 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 不可用');