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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -794,6 +794,7 @@
|
||||
|
||||
.shop-scan-auth-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user