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 | null = null; let configured = false; let configuredUrl: string | null = null; /** * iOS 微信 WebView:JSSDK 签名校验用的是「本次 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 { 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('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 = { '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; } /** 初始化微信 JSSDK(H5 公众号内) */ export async function initWechatJssdk(options: { apiBase: string; clientApp: string; url?: string; getAccessToken?: () => string | null; jsApiList?: string[]; }): Promise { 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((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 { captureIosJssdkEntryUrl(); const pageUrl = getJssdkSignUrl(options.url); if (configuredUrl && signUrlChanged(configuredUrl, pageUrl)) { resetJssdkConfig(); } if (!isJssdkReady()) { await initWechatJssdk({ ...options, url: pageUrl }); } } if (typeof window !== 'undefined') { captureIosJssdkEntryUrl(); }