diff --git a/AGENTS.md b/AGENTS.md index 2121fd6..b06cb03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,8 @@ C 端门店仅 status=OPEN 订单 Tab:待付款 | 已付款 | 已完成 ``` +**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。 + ## 环境与发版 | 环境 | 分支 | 目录 | 端口 | 域名 | diff --git a/apps/h5-shop/src/components/WechatScanAuthModal.tsx b/apps/h5-shop/src/components/WechatScanAuthModal.tsx index d557326..0a06fca 100644 --- a/apps/h5-shop/src/components/WechatScanAuthModal.tsx +++ b/apps/h5-shop/src/components/WechatScanAuthModal.tsx @@ -2,7 +2,10 @@ type WechatScanAuthModalProps = { open: boolean; loading?: boolean; error?: string; + /** bind=首次绑定;recover=扫码 JSSDK 失败后的恢复引导 */ + mode?: 'bind' | 'recover'; onAuthorize: () => void; + onRefresh?: () => void; onCancel: () => void; }; @@ -10,28 +13,43 @@ export default function WechatScanAuthModal({ open, loading, error, + mode = 'bind', onAuthorize, + onRefresh, onCancel, }: WechatScanAuthModalProps) { if (!open) return null; + const isRecover = mode === 'recover'; + return (
- qr_code_scanner + + {isRecover ? 'sync_problem' : 'qr_code_scanner'} +
-

微信授权

+

+ {isRecover ? '扫码能力未就绪' : '微信授权'} +

- 扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。 + {isRecover + ? '微信扫码接口校验失败(常见于 iPhone 登录/授权后)。请先刷新页面;仍失败再重新授权微信。' + : '扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。'}

{error &&

{error}

}
+ {isRecover && onRefresh ? ( + + ) : null}
diff --git a/apps/h5-shop/src/lib/wechat-auth.ts b/apps/h5-shop/src/lib/wechat-auth.ts index d0acec9..7cddb26 100644 --- a/apps/h5-shop/src/lib/wechat-auth.ts +++ b/apps/h5-shop/src/lib/wechat-auth.ts @@ -169,11 +169,12 @@ export async function loginShopWithWechat(): Promise { +export async function bindShopWechatAfterSmsLogin(session?: ShopSessionPayload): Promise<'skipped' | void> { const config = await fetchClientConfig(); - if (!isWxAuthorizeEnabled(config)) return; - if (!isWechatEnv()) return; - // OAuth 整页回跳后 iOS 需用入场 URL 重配 JSSDK;标记下次扫码加长预热 + if (!isWxAuthorizeEnabled(config)) return 'skipped'; + if (!isWechatEnv()) return 'skipped'; + // 已绑定则勿再 OAuth:每次 OAuth 回跳都会重置 iOS JSSDK 入场 URL,易导致扫码失败 + if (session?.account?.hasWechat) return 'skipped'; markScanWarmupAfterAuth(); await weixinSdk.login(); } diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx index 54920a3..b0cd14d 100644 --- a/apps/h5-shop/src/pages/HomePage.tsx +++ b/apps/h5-shop/src/pages/HomePage.tsx @@ -58,9 +58,9 @@ function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string { const msg = e instanceof Error ? e.message : '扫码失败,请重试'; - if (/invalid signature/i.test(msg)) { + if (/invalid signature|config:fail|signature/i.test(msg)) { - return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试'; + return '微信扫码签名校验失败,请刷新页面或重新授权微信后重试'; } @@ -68,11 +68,11 @@ function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string { if (opts?.afterAuth) { - return '微信授权已完成,扫码权限仍在准备中,请再点一次「扫码核销」'; + return '微信授权后扫码仍未就绪,请刷新页面或重新授权微信'; } - return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)'; + return '微信扫码能力未就绪,请刷新页面或重新授权微信'; } @@ -80,6 +80,12 @@ function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string { } +function isScanRecoverableError(msg: string): boolean { + + return isScanPermissionWarmupError(msg) || /签名校验失败|扫码能力未就绪|请刷新页面/i.test(msg); + +} + export default function HomePage() { @@ -100,6 +106,8 @@ export default function HomePage() { const [authModalOpen, setAuthModalOpen] = useState(false); + const [authModalMode, setAuthModalMode] = useState<'bind' | 'recover'>('bind'); + const [authLoading, setAuthLoading] = useState(false); const [authError, setAuthError] = useState(''); @@ -229,7 +237,19 @@ export default function HomePage() { } catch (e) { - setScanMsg(formatScanError(e, { afterAuth: opts?.postAuthWarmup })); + const tip = formatScanError(e, { afterAuth: opts?.postAuthWarmup }); + + setScanMsg(tip); + + if (isScanRecoverableError(tip)) { + + setAuthModalMode('recover'); + + setAuthError(tip); + + setAuthModalOpen(true); + + } } finally { @@ -305,6 +325,10 @@ export default function HomePage() { pendingScanStartedRef.current = false; + setAuthModalMode('bind'); + + setAuthError(''); + setAuthModalOpen(true); return; @@ -561,16 +585,26 @@ export default function HomePage() { open={authModalOpen} + mode={authModalMode} + loading={authLoading} error={authError} onAuthorize={() => void startWechatAuth()} + onRefresh={() => { + + window.location.reload(); + + }} + onCancel={() => { setAuthModalOpen(false); + setAuthModalMode('bind'); + setAuthError(''); clearPendingScanAfterAuth(); diff --git a/apps/h5-shop/src/pages/LoginPage.tsx b/apps/h5-shop/src/pages/LoginPage.tsx index 0aa5212..b2ee058 100644 --- a/apps/h5-shop/src/pages/LoginPage.tsx +++ b/apps/h5-shop/src/pages/LoginPage.tsx @@ -159,9 +159,9 @@ export default function LoginPage() { }); saveRememberedSession(data); applySession(data); - if (isWechatEnv() && wxAuthorize) { + if (isWechatEnv() && wxAuthorize && !data.account?.hasWechat) { setMsg('登录成功,正在关联微信…'); - await bindShopWechatAfterSmsLogin(); + await bindShopWechatAfterSmsLogin(data); return; } routeAfterShopLogin(data, navigate); diff --git a/apps/h5-shop/src/pages/SelectStorePage.tsx b/apps/h5-shop/src/pages/SelectStorePage.tsx index b6b9e6c..e1677a2 100644 --- a/apps/h5-shop/src/pages/SelectStorePage.tsx +++ b/apps/h5-shop/src/pages/SelectStorePage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; +import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk'; import { useStoreSession } from '../contexts/StoreSessionContext'; import { needsStoreSelection, @@ -10,6 +11,14 @@ import { type ShopStoreOption, } from '../lib/api'; +function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) { + if (shouldHardNavigateForJssdk()) { + hardNavigateInWechat('/'); + return; + } + navigate('/', { replace: true }); +} + export default function SelectStorePage() { const navigate = useNavigate(); const { applySession, store, authenticated } = useStoreSession(); @@ -37,7 +46,7 @@ export default function SelectStorePage() { async function onSelect(storeId: string) { if (loadingId) return; if (storeId === currentStoreId) { - navigate('/', { replace: true }); + goShopHome(navigate); return; } setLoadingId(storeId); @@ -45,7 +54,7 @@ export default function SelectStorePage() { try { const session = await selectStore(storeId); applySession(session); - navigate('/', { replace: true }); + goShopHome(navigate); } catch (e) { setMsg(e instanceof Error ? e.message : '选店失败'); } finally { @@ -166,9 +175,11 @@ export function routeAfterShopLogin( session: ShopSessionPayload, navigate: (path: string, opts?: { replace?: boolean }) => void, ) { - if (needsStoreSelection(session)) { - navigate('/select-store', { replace: true }); + const path = needsStoreSelection(session) ? '/select-store' : '/'; + // iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂 + if (shouldHardNavigateForJssdk()) { + hardNavigateInWechat(path); return; } - navigate('/', { replace: true }); + navigate(path, { replace: true }); } diff --git a/apps/h5-shop/src/styles.css b/apps/h5-shop/src/styles.css index cf6bc02..3fa354b 100644 --- a/apps/h5-shop/src/styles.css +++ b/apps/h5-shop/src/styles.css @@ -794,6 +794,7 @@ .shop-scan-auth-actions { display: flex; + flex-wrap: wrap; gap: 12px; } diff --git a/packages/weixin-sdk/GOTCHAS.md b/packages/weixin-sdk/GOTCHAS.md new file mode 100644 index 0000000..b143325 --- /dev/null +++ b/packages/weixin-sdk/GOTCHAS.md @@ -0,0 +1,34 @@ +# @dukang/weixin-sdk · 踩坑 + +## iOS 微信 H5:JSSDK 入场 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 上返回入场 URL;OAuth 的 `code/state` **必须保留**参与签名;后端 `jssdk-config` 只去 `#`,不要删 query。 +3. **先 `captureIosJssdkEntryUrl()`,再 `stripOAuthParamsFromLocation()`**。 +4. 失败恢复:引导用户刷新页面或重新走 OAuth,而不是无限「再点一次」。 + +### 相关 API + +- `captureIosJssdkEntryUrl` / `getJssdkSignUrl` +- `shouldHardNavigateForJssdk` / `hardNavigateInWechat` +- `stripOAuthParamsFromLocation` diff --git a/packages/weixin-sdk/src/index.ts b/packages/weixin-sdk/src/index.ts index 589e94c..91b6814 100644 --- a/packages/weixin-sdk/src/index.ts +++ b/packages/weixin-sdk/src/index.ts @@ -5,10 +5,13 @@ export { ensureJssdkReady, isJssdkReady, normalizeJssdkPageUrl, + jssdkUrlWithoutHash, getJssdkSignUrl, captureIosJssdkEntryUrl, resetJssdkConfig, stripOAuthParamsFromLocation, + hardNavigateInWechat, + shouldHardNavigateForJssdk, } from './jssdk'; export { formatScanFailMessage, isScanPermissionWarmupError } from './scan'; export { diff --git a/packages/weixin-sdk/src/jssdk.ts b/packages/weixin-sdk/src/jssdk.ts index 2dce4b2..8a819c2 100644 --- a/packages/weixin-sdk/src/jssdk.ts +++ b/packages/weixin-sdk/src/jssdk.ts @@ -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 { captureIosJssdkEntryUrl(); const { apiBase, clientApp, getAccessToken } = options; - // iOS 忽略调用方传入的「当前页」URL,强制入场 URL,避免登录后 SPA 到首页签错名 const pageUrl = getJssdkSignUrl(options.url); await loadScript(); if (!window.wx) throw new Error('微信 JSSDK 不可用'); diff --git a/server/dukang-api/src/modules/common/wechat.controller.ts b/server/dukang-api/src/modules/common/wechat.controller.ts index 689603d..eb7df85 100644 --- a/server/dukang-api/src/modules/common/wechat.controller.ts +++ b/server/dukang-api/src/modules/common/wechat.controller.ts @@ -83,17 +83,12 @@ export class WechatController { return this.wechat.createJssdkConfig(pageUrl, actorRef); } + /** + * 仅去 hash。勿剔除 code/state: + * iOS 微信用「document 入场 URL」验签,OAuth 回跳页的 query 必须原样参与签名。 + */ private normalizeJssdkUrl(rawUrl: string): string { - try { - const parsed = new URL(rawUrl); - parsed.hash = ''; - parsed.searchParams.delete('code'); - parsed.searchParams.delete('state'); - const query = parsed.searchParams.toString(); - return `${parsed.origin}${parsed.pathname}${query ? `?${query}` : ''}`; - } catch { - return rawUrl.split('#')[0]; - } + return rawUrl.split('#')[0]; } @Get('oauth-url') diff --git a/杜康好客-知识库.md b/杜康好客-知识库.md index 5e1e2b8..f20c768 100644 --- a/杜康好客-知识库.md +++ b/杜康好客-知识库.md @@ -34,7 +34,28 @@ - 核销记录;今日汇总;到账金额×60%展示 - 营业状态开关;Mine 门店信息 - 套餐:列表编辑→提交 HQ 审核(v3.4.10) -- iOS 微信:OAuth 后自动续扫(v3.4.13) +- iOS 微信:OAuth 后自动续扫(v3.4.13);登录后须整页跳转(见下「踩坑」) + +### 踩坑 · iOS 微信 H5 扫码(必读,勿再回归) + +**现象**:手机号重新登录后点「扫码核销」提示「微信权限校验尚未完成…」;关掉 H5 再进就正常。 + +**根因(不是系统相机权限)**: + +1. iOS 微信 WebView 对 JSSDK 验签用的是**本次 document 加载的入场 URL**(含 query),不是 SPA `pushState` 之后的 `location.href`。 +2. 登录 / OAuth 回跳常落在 `/login?code=…`,再 `navigate('/')` 进首页 → 签名 URL 与微信内部入场 URL 不一致 → `permission value is offline verifying` / invalid signature。 +3. 文案「等 1~2 秒再点」只覆盖「权限离线校验偏慢」的一小部分场景;**签名错了等多久都不行**,必须整页刷新或重新授权。 + +**硬规则(编码)**: + +| 规则 | 说明 | +|------|------| +| iOS 登录/选店后 | 用 `location.replace(path)`(`hardNavigateInWechat`),禁止仅 React Router navigate | +| iOS 签名 URL | `getJssdkSignUrl()` = 入场 URL,**保留** OAuth `code/state`;后端 `jssdk-config` 勿剔除 | +| 已绑定微信 | 短信登录后**不要**再强制 OAuth(避免反复重置入场 URL) | +| 扫码仍失败 | 弹窗引导「刷新页面」/「重新授权微信」,勿只提示再点一次 | + +实现:`packages/weixin-sdk/src/jssdk.ts` · `apps/h5-shop` 登录/选店/HomePage。 ## 4. 合伙人端(h5-partner)