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
@@ -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 (
<div className="shop-scan-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="shop-scan-auth-title">
<div className="shop-scan-auth-card">
<div className="shop-scan-auth-icon">
<span className="material-symbols-outlined shop-fill-icon">qr_code_scanner</span>
<span className="material-symbols-outlined shop-fill-icon">
{isRecover ? 'sync_problem' : 'qr_code_scanner'}
</span>
</div>
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title"></h2>
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title">
{isRecover ? '扫码能力未就绪' : '微信授权'}
</h2>
<p className="shop-scan-auth-desc">
使使
{isRecover
? '微信扫码接口校验失败(常见于 iPhone 登录/授权后)。请先刷新页面;仍失败再重新授权微信。'
: '扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。'}
</p>
{error && <p className="shop-scan-auth-error" role="alert">{error}</p>}
<div className="shop-scan-auth-actions">
<button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}>
</button>
{isRecover && onRefresh ? (
<button type="button" className="shop-scan-auth-confirm" onClick={onRefresh} disabled={loading}>
</button>
) : null}
<button type="button" className="shop-scan-auth-confirm" onClick={onAuthorize} disabled={loading}>
{loading ? '跳转授权中…' : '微信授权'}
{loading ? '跳转授权中…' : isRecover ? '重新授权微信' : '微信授权'}
</button>
</div>
</div>
+5 -4
View File
@@ -169,11 +169,12 @@ export async function loginShopWithWechat(): Promise<ShopSessionPayload | null |
}
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
export async function bindShopWechatAfterSmsLogin(): Promise<void> {
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();
}
+39 -5
View File
@@ -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();
+2 -2
View File
@@ -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);
+16 -5
View File
@@ -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 });
}
+1
View File
@@ -794,6 +794,7 @@
.shop-scan-auth-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}