From 3d3588babbb92f4227b2af88d46055e4f3a264a7 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Thu, 9 Jul 2026 16:49:01 +0800 Subject: [PATCH] fix(weixin-sdk): fix iOS scanQRCode false camera permission error Co-authored-by: Cursor --- apps/h5-shop/src/pages/HomePage.tsx | 3 + packages/weixin-sdk/src/chooseImage.ts | 5 +- packages/weixin-sdk/src/env.ts | 12 ++++ packages/weixin-sdk/src/index.ts | 11 ++- packages/weixin-sdk/src/jssdk.ts | 48 ++++++++++--- packages/weixin-sdk/src/scan.ts | 94 ++++++++++++++++++++------ 6 files changed, 141 insertions(+), 32 deletions(-) diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx index e78d00c..23940ab 100644 --- a/apps/h5-shop/src/pages/HomePage.tsx +++ b/apps/h5-shop/src/pages/HomePage.tsx @@ -26,6 +26,9 @@ function formatScanError(e: unknown): string { if (/invalid signature/i.test(msg)) { return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试'; } + if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) { + return `${msg}(可在 URL 后加 ?wxdebug=1 开启 JSSDK 调试查看详情)`; + } return msg; } diff --git a/packages/weixin-sdk/src/chooseImage.ts b/packages/weixin-sdk/src/chooseImage.ts index 3506384..f360ad4 100644 --- a/packages/weixin-sdk/src/chooseImage.ts +++ b/packages/weixin-sdk/src/chooseImage.ts @@ -1,5 +1,5 @@ import { getRuntimePlatform } from './env'; -import { ensureJssdkReady, normalizeJssdkPageUrl } from './jssdk'; +import { ensureJssdkReady, normalizeJssdkPageUrl, getJssdkSignUrl } from './jssdk'; import type { WeixinSdkConfig } from './types'; export type ChooseWechatImageOptions = { @@ -84,14 +84,13 @@ export async function chooseWechatImages( const sourceType = options.sourceType ?? ['album', 'camera']; if (platform === 'wechat-h5') { - const pageUrl = typeof window !== 'undefined' ? normalizeJssdkPageUrl(window.location.href) : ''; + const pageUrl = typeof window !== 'undefined' ? getJssdkSignUrl() : ''; const sourceTypeKey = sourceType.join(','); try { await ensureJssdkReady({ apiBase: config.apiBase ?? '/api/v1', clientApp: config.clientApp, getAccessToken: config.getAccessToken, - url: pageUrl, jsApiList: ['chooseImage', 'getLocalImgData'], }); } catch (e) { diff --git a/packages/weixin-sdk/src/env.ts b/packages/weixin-sdk/src/env.ts index d6e4882..2544a15 100644 --- a/packages/weixin-sdk/src/env.ts +++ b/packages/weixin-sdk/src/env.ts @@ -1,3 +1,15 @@ +/** 是否 iOS 设备(不含微信开发者工具模拟器特殊处理) */ +export function isIosDevice(): boolean { + if (typeof navigator === 'undefined') return false; + return /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as Window & { MSStream?: unknown }).MSStream; +} + +/** 是否微信开发者工具 */ +export function isWechatDevTools(): boolean { + if (typeof navigator === 'undefined') return false; + return /wechatdevtools/i.test(navigator.userAgent); +} + /** 是否微信内置浏览器 */ export function isWechatBrowser(): boolean { if (typeof navigator === 'undefined') return false; diff --git a/packages/weixin-sdk/src/index.ts b/packages/weixin-sdk/src/index.ts index abbedb6..47c8d7c 100644 --- a/packages/weixin-sdk/src/index.ts +++ b/packages/weixin-sdk/src/index.ts @@ -1,6 +1,15 @@ export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env'; export { getRouterBasename, toAppPath, isOnAppPath } from './app-path'; -export { initWechatJssdk, ensureJssdkReady, isJssdkReady, normalizeJssdkPageUrl, stripOAuthParamsFromLocation } from './jssdk'; +export { + initWechatJssdk, + ensureJssdkReady, + isJssdkReady, + normalizeJssdkPageUrl, + getJssdkSignUrl, + captureIosJssdkEntryUrl, + stripOAuthParamsFromLocation, +} from './jssdk'; +export { formatScanFailMessage } from './scan'; export { getWechatLocation, getWechatLocationDetailed, diff --git a/packages/weixin-sdk/src/jssdk.ts b/packages/weixin-sdk/src/jssdk.ts index ec3dd42..8f7657d 100644 --- a/packages/weixin-sdk/src/jssdk.ts +++ b/packages/weixin-sdk/src/jssdk.ts @@ -1,16 +1,14 @@ 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'; +const IOS_ENTRY_URL_KEY = 'dukang_wx_ios_entry_url'; let scriptPromise: Promise | null = null; let configured = false; let configuredUrl: string | null = null; -function currentPageUrl() { - return typeof window !== 'undefined' ? normalizeJssdkPageUrl(window.location.href) : ''; -} - /** 参与 JSSDK 签名的页面 URL(去掉 hash、OAuth 回跳参数) */ export function normalizeJssdkPageUrl(rawUrl: string): string { try { @@ -25,6 +23,33 @@ export function normalizeJssdkPageUrl(rawUrl: string): string { } } +/** iOS 微信内 SPA:签名 URL 须用首次进入页面的入口 URL,而非路由跳转后的当前 URL */ +export function captureIosJssdkEntryUrl(): void { + if (typeof window === 'undefined') return; + if (!isIosDevice() || !isWechatBrowser() || isWechatDevTools()) return; + if (sessionStorage.getItem(IOS_ENTRY_URL_KEY)) return; + sessionStorage.setItem(IOS_ENTRY_URL_KEY, normalizeJssdkPageUrl(window.location.href)); +} + +/** 获取参与 JSSDK 签名的 URL */ +export function getJssdkSignUrl(rawUrl?: string): string { + const current = normalizeJssdkPageUrl(rawUrl ?? (typeof window !== 'undefined' ? window.location.href : '')); + if (typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools()) { + const entry = sessionStorage.getItem(IOS_ENTRY_URL_KEY); + if (entry) return entry; + } + return current; +} + +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); @@ -77,8 +102,9 @@ export async function initWechatJssdk(options: { getAccessToken?: () => string | null; jsApiList?: string[]; }): Promise { + captureIosJssdkEntryUrl(); const { apiBase, clientApp, getAccessToken } = options; - const pageUrl = options.url ?? currentPageUrl(); + const pageUrl = options.url ?? getJssdkSignUrl(); await loadScript(); if (!window.wx) throw new Error('微信 JSSDK 不可用'); @@ -92,7 +118,7 @@ export async function initWechatJssdk(options: { window.wx!.config({ ...config, jsApiList, - debug: false, + debug: isJssdkDebugEnabled(), }); window.wx!.ready(() => { configured = true; @@ -104,7 +130,8 @@ export async function initWechatJssdk(options: { } export function isJssdkReady(): boolean { - return configured && !!window.wx && configuredUrl === currentPageUrl(); + const signUrl = getJssdkSignUrl(); + return configured && !!window.wx && configuredUrl === signUrl; } export async function ensureJssdkReady(options: { @@ -114,8 +141,13 @@ export async function ensureJssdkReady(options: { getAccessToken?: () => string | null; jsApiList?: string[]; }): Promise { - const pageUrl = options.url ?? currentPageUrl(); + captureIosJssdkEntryUrl(); + const pageUrl = options.url ?? getJssdkSignUrl(); if (!isJssdkReady()) { await initWechatJssdk({ ...options, url: pageUrl }); } } + +if (typeof window !== 'undefined') { + captureIosJssdkEntryUrl(); +} diff --git a/packages/weixin-sdk/src/scan.ts b/packages/weixin-sdk/src/scan.ts index f1e2954..e14c6c3 100644 --- a/packages/weixin-sdk/src/scan.ts +++ b/packages/weixin-sdk/src/scan.ts @@ -1,7 +1,57 @@ -import { getRuntimePlatform } from './env'; +import { getRuntimePlatform, isIosDevice, isWechatDevTools } from './env'; import { ensureJssdkReady } from './jssdk'; import type { WeixinSdkConfig } from './types'; +function delay(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +/** 将微信 scanQRCode fail 的 errMsg 转为用户可读文案(避免误判为系统相机权限) */ +export function formatScanFailMessage(errMsg: string): string { + const msg = errMsg.trim() || '扫码失败'; + if (/cancel/i.test(msg)) return ''; + + // iOS 常见:JSSDK 权限离线校验尚未完成,并非系统相机权限 + if (/offline verifying|permission value is offline/i.test(msg)) { + return '微信权限验证中,请稍候再试或刷新页面后重新扫码'; + } + + if (/invalid signature|config:fail|signature/i.test(msg)) { + return '微信 JSSDK 签名校验失败,请刷新页面后重试'; + } + + // 系统级相机权限(iPhone 设置 → 隐私 → 相机 → 微信) + if (/system.*camera|NotAllowedError|camera.*not.*allowed|无法访问相机|无相机权限/i.test(msg)) { + return '相机权限未开启,请在 iPhone「设置 → 隐私与安全性 → 相机」中允许微信使用摄像头'; + } + + // JSSDK 接口授权失败(含 permission,但不等于相机权限) + if (/permission|auth|denied|授权|拒绝/i.test(msg)) { + return `微信扫码接口未就绪(${msg}),请刷新页面后重试`; + } + + return msg; +} + +function invokeScanQrCode(): Promise { + return new Promise((resolve, reject) => { + window.wx!.scanQRCode!({ + needResult: 1, + scanType: ['qrCode', 'barCode'], + success: (res) => resolve(res.resultStr || null), + fail: (res) => { + const msg = res.errMsg || '扫码失败'; + if (/cancel/i.test(msg)) { + resolve(null); + return; + } + const formatted = formatScanFailMessage(msg); + reject(new Error(formatted || msg)); + }, + }); + }); +} + /** 调起扫码(返回二维码/条码内容,失败返回 null) */ export async function scanQrCode(config: WeixinSdkConfig): Promise { const platform = getRuntimePlatform(); @@ -26,25 +76,29 @@ export async function scanQrCode(config: WeixinSdkConfig): Promise { - window.wx!.scanQRCode!({ - needResult: 1, - scanType: ['qrCode', 'barCode'], - success: (res) => resolve(res.resultStr || null), - fail: (res) => { - const msg = res.errMsg || '扫码失败'; - if (/cancel/i.test(msg)) { - resolve(null); - return; - } - if (/permission|auth|denied|授权|拒绝|camera/i.test(msg)) { - reject(new Error('相机权限未开启,请在微信设置中允许使用摄像头')); - return; - } - reject(new Error(msg)); - }, - }); - }); + + // iOS 在 wx.ready 后立即调 scanQRCode 可能触发 offline verifying + if (isIosDevice() && !isWechatDevTools()) { + await delay(500); + } + + const maxAttempts = 2; + let lastError: Error | null = null; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (attempt > 0) { + await delay(800); + } + try { + return await invokeScanQrCode(); + } catch (e) { + lastError = e instanceof Error ? e : new Error('扫码失败'); + const raw = lastError.message; + const retryable = /offline verifying|权限验证中|接口未就绪/i.test(raw); + if (attempt < maxAttempts - 1 && retryable) continue; + throw lastError; + } + } + throw lastError ?? new Error('扫码失败'); } throw new Error('请在微信内打开以使用扫码核销');