Files
dukang/packages/weixin-sdk/src/jssdk.ts
T
jacy e4e9eb2169 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>
2026-08-07 13:08:13 +08:00

226 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { WechatJssdkConfig } from '@dukang/shared-types';
import { isIosDevice, isWechatBrowser, isWechatDevTools } from './env';
import { DEFAULT_JS_API_LIST } from './types';
const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
/** 旧版错误地把 SPA 当前 URL 写入 session;清理以免干扰排查 */
const LEGACY_SIGN_URL_CACHE_KEY = 'dukang_wx_sign_url_v2';
let scriptPromise: Promise<void> | null = null;
let configured = false;
let configuredUrl: string | null = null;
/**
* iOS 微信 WebViewJSSDK 签名校验用的是「本次 document 加载」的入场 URL(含 query),
* SPA pushState/replaceState 后 location.href 会变,但微信仍按入场 URL 验签。
* OAuth 回跳带 code/state 时也必须按入场完整 query 签名,不可剔除。
* 模块级变量:整页刷新(含 OAuth / location.replace)会重置;同页 SPA 保持不变。
*/
let iosEntryUrl: string | null = null;
/** 清除 JSSDK 配置缓存(路由切换后须重新 wx.config) */
export function resetJssdkConfig(): void {
configured = false;
configuredUrl = null;
}
/** 仅去 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 = '';
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 = jssdkUrlWithoutHash(rawUrl);
if (keepOAuth) return noHash;
try {
const url = new URL(noHash, typeof window !== 'undefined' ? window.location.origin : 'https://localhost');
url.searchParams.delete('code');
url.searchParams.delete('state');
const query = url.searchParams.toString();
return `${url.origin}${url.pathname}${query ? `?${query}` : ''}`;
} catch {
return noHash;
}
}
}
function signUrlChanged(prev: string | null, current: string): boolean {
return prev !== current;
}
function clearLegacySignUrlCache(): void {
try {
sessionStorage.removeItem(LEGACY_SIGN_URL_CACHE_KEY);
} catch {
/* ignore */
}
}
/**
* 捕获 iOS 微信入场 URL(每个 document 生命周期只记一次;保留 code/state)。
* Android / 非微信环境为 no-op。
*/
export function captureIosJssdkEntryUrl(): void {
if (typeof window === 'undefined') return;
if (!isIosDevice() || !isWechatBrowser() || isWechatDevTools()) return;
clearLegacySignUrlCache();
if (iosEntryUrl) return;
iosEntryUrl = normalizeJssdkPageUrl(window.location.href, { keepOAuthQuery: true });
}
/** 获取参与 JSSDK 签名的 URL;iOS 微信内固定为本次入场 URL(含 OAuth query */
export function getJssdkSignUrl(rawUrl?: string): string {
if (typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools()) {
captureIosJssdkEntryUrl();
if (iosEntryUrl) return iosEntryUrl;
}
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 {
if (typeof window === 'undefined') return false;
try {
return new URLSearchParams(window.location.search).get('wxdebug') === '1';
} catch {
return false;
}
}
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(含 code/state),再 replaceState
captureIosJssdkEntryUrl();
url.searchParams.delete('code');
url.searchParams.delete('state');
const query = url.searchParams.toString();
const next = `${url.pathname}${query ? `?${query}` : ''}${url.hash}`;
window.history.replaceState({}, '', next);
}
function loadScript(): Promise<void> {
if (typeof document === 'undefined') return Promise.reject(new Error('非浏览器环境'));
if (window.wx) return Promise.resolve();
if (scriptPromise) return scriptPromise;
scriptPromise = new Promise((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>('script[data-dukang-wx-jssdk]');
if (existing) {
existing.addEventListener('load', () => resolve());
existing.addEventListener('error', () => reject(new Error('微信 JSSDK 加载失败')));
return;
}
const script = document.createElement('script');
script.src = JSSDK_URL;
script.async = true;
script.dataset.dukangWxJssdk = '1';
script.onload = () => resolve();
script.onerror = () => reject(new Error('微信 JSSDK 加载失败'));
document.head.appendChild(script);
});
return scriptPromise;
}
async function fetchJssdkConfig(apiBase: string, clientApp: string, url: string, token?: string | null) {
const headers: Record<string, string> = { 'X-Client-App': clientApp };
if (token) headers.Authorization = `Bearer ${token}`;
const qs = new URLSearchParams({ url });
const res = await fetch(`${apiBase}/common/wechat/jssdk-config?${qs}`, { headers });
const json = await res.json();
if (json.code !== 0) throw new Error(json.message || '获取 JSSDK 配置失败');
return json.data as WechatJssdkConfig;
}
/** 初始化微信 JSSDKH5 公众号内) */
export async function initWechatJssdk(options: {
apiBase: string;
clientApp: string;
url?: string;
getAccessToken?: () => string | null;
jsApiList?: string[];
}): Promise<void> {
captureIosJssdkEntryUrl();
const { apiBase, clientApp, getAccessToken } = options;
const pageUrl = getJssdkSignUrl(options.url);
await loadScript();
if (!window.wx) throw new Error('微信 JSSDK 不可用');
const config = await fetchJssdkConfig(apiBase, clientApp, pageUrl, getAccessToken?.());
const jsApiList = options.jsApiList ?? [...DEFAULT_JS_API_LIST];
configured = false;
configuredUrl = null;
await new Promise<void>((resolve, reject) => {
window.wx!.config({
...config,
jsApiList,
debug: isJssdkDebugEnabled(),
});
window.wx!.ready(() => {
configured = true;
configuredUrl = pageUrl;
resolve();
});
window.wx!.error((err) => reject(new Error(err.errMsg || 'wx.config 失败')));
});
}
export function isJssdkReady(): boolean {
const signUrl = getJssdkSignUrl();
return configured && !!window.wx && configuredUrl === signUrl;
}
export async function ensureJssdkReady(options: {
apiBase: string;
clientApp: string;
url?: string;
getAccessToken?: () => string | null;
jsApiList?: string[];
}): Promise<void> {
captureIosJssdkEntryUrl();
const pageUrl = getJssdkSignUrl(options.url);
if (configuredUrl && signUrlChanged(configuredUrl, pageUrl)) {
resetJssdkConfig();
}
if (!isJssdkReady()) {
await initWechatJssdk({ ...options, url: pageUrl });
}
}
if (typeof window !== 'undefined') {
captureIosJssdkEntryUrl();
}