merge(dev): fix iOS shop scan JSSDK entry-URL signing
CI / verify (push) Has been cancelled

This commit is contained in:
2026-08-07 12:54:01 +08:00
4 changed files with 76 additions and 22 deletions
+22
View File
@@ -3,6 +3,9 @@ import { isIosDevice } from '@dukang/weixin-sdk';
/** 扫码前发起 OAuth 时标记,回跳后在首页续扫 */ /** 扫码前发起 OAuth 时标记,回跳后在首页续扫 */
export const SHOP_PENDING_SCAN_KEY = 'shop_pending_scan'; export const SHOP_PENDING_SCAN_KEY = 'shop_pending_scan';
/** 短信登录后绑定微信等 OAuth 回跳:下次手动扫码加长预热(不自动打开相机) */
export const SHOP_SCAN_WARMUP_KEY = 'shop_scan_warmup';
export function markPendingScanAfterAuth(): void { export function markPendingScanAfterAuth(): void {
try { try {
sessionStorage.setItem(SHOP_PENDING_SCAN_KEY, '1'); sessionStorage.setItem(SHOP_PENDING_SCAN_KEY, '1');
@@ -27,6 +30,25 @@ export function clearPendingScanAfterAuth(): void {
} }
} }
/** OAuth(含短信登录后绑微信)回跳后,标记下一次扫码需要加长预热 */
export function markScanWarmupAfterAuth(): void {
try {
sessionStorage.setItem(SHOP_SCAN_WARMUP_KEY, '1');
} catch {
/* ignore */
}
}
export function consumeScanWarmupAfterAuth(): boolean {
try {
if (sessionStorage.getItem(SHOP_SCAN_WARMUP_KEY) !== '1') return false;
sessionStorage.removeItem(SHOP_SCAN_WARMUP_KEY);
return true;
} catch {
return false;
}
}
/** OAuth 回跳后延迟再调 scanQRCodeiOS JSSDK 离线校验更慢) */ /** OAuth 回跳后延迟再调 scanQRCodeiOS JSSDK 离线校验更慢) */
export function getPostAuthScanDelayMs(): number { export function getPostAuthScanDelayMs(): number {
return isIosDevice() ? 1200 : 600; return isIosDevice() ? 1200 : 600;
+5
View File
@@ -2,6 +2,7 @@ import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-type
import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk'; import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from './weixin'; import { isWechatEnv, weixinSdk } from './weixin';
import { markScanWarmupAfterAuth } from './shop-scan-auth';
import { request, saveWechatSession, type ShopSessionPayload } from './api'; import { request, saveWechatSession, type ShopSessionPayload } from './api';
export type ShopAccountProfile = { export type ShopAccountProfile = {
@@ -162,6 +163,7 @@ export async function loginShopWithWechat(): Promise<ShopSessionPayload | null |
if (!isWechatEnv()) { if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG); throw new Error(WECHAT_INAPP_REQUIRED_MSG);
} }
markScanWarmupAfterAuth();
const result = await weixinSdk.login(); const result = await weixinSdk.login();
if (result) return handleShopWechatLoginResult(result); if (result) return handleShopWechatLoginResult(result);
} }
@@ -171,6 +173,8 @@ export async function bindShopWechatAfterSmsLogin(): Promise<void> {
const config = await fetchClientConfig(); const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return; if (!isWxAuthorizeEnabled(config)) return;
if (!isWechatEnv()) return; if (!isWechatEnv()) return;
// OAuth 整页回跳后 iOS 需用入场 URL 重配 JSSDK;标记下次扫码加长预热
markScanWarmupAfterAuth();
await weixinSdk.login(); await weixinSdk.login();
} }
@@ -180,5 +184,6 @@ export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
if (!isWechatEnv()) { if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG); throw new Error(WECHAT_INAPP_REQUIRED_MSG);
} }
markScanWarmupAfterAuth();
return weixinSdk.login(); return weixinSdk.login();
} }
+8 -3
View File
@@ -4,7 +4,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isScanPermissionWarmupError } from '@dukang/weixin-sdk'; import { isIosDevice, isScanPermissionWarmupError } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
@@ -28,6 +28,8 @@ import {
clearPendingScanAfterAuth, clearPendingScanAfterAuth,
consumeScanWarmupAfterAuth,
getPostAuthScanDelayMs, getPostAuthScanDelayMs,
markPendingScanAfterAuth, markPendingScanAfterAuth,
@@ -190,7 +192,8 @@ export default function HomePage() {
try { try {
if (opts?.postAuthWarmup) { // iOS / OAuth 回跳后须重新 wx.config(签名用入场 URL
if (opts?.postAuthWarmup || isIosDevice()) {
weixinSdk.reset(); weixinSdk.reset();
@@ -308,7 +311,9 @@ export default function HomePage() {
} }
await runScan(); const needWarmup = consumeScanWarmupAfterAuth();
await runScan(needWarmup ? { postAuthWarmup: true } : undefined);
} catch (e) { } catch (e) {
+41 -19
View File
@@ -1,14 +1,22 @@
import type { WechatJssdkConfig } from '@dukang/shared-types'; import type { WechatJssdkConfig } from '@dukang/shared-types';
import { isWechatBrowser, isWechatDevTools } from './env'; import { isIosDevice, isWechatBrowser, isWechatDevTools } from './env';
import { DEFAULT_JS_API_LIST } from './types'; import { DEFAULT_JS_API_LIST } from './types';
const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const SIGN_URL_CACHE_KEY = 'dukang_wx_sign_url_v2'; /** 旧版错误地把 SPA 当前 URL 写入 session;清理以免干扰排查 */
const LEGACY_SIGN_URL_CACHE_KEY = 'dukang_wx_sign_url_v2';
let scriptPromise: Promise<void> | null = null; let scriptPromise: Promise<void> | null = null;
let configured = false; let configured = false;
let configuredUrl: string | null = null; let configuredUrl: string | null = null;
/**
* iOS 微信 WebViewJSSDK 签名校验用的是「本次 document 加载」的入场 URL
* SPA pushState/replaceState 后 location.href 会变,但微信仍按入场 URL 验签。
* 使用模块级变量:整页刷新(含 OAuth 回跳)会重置;同页 SPA 路由保持不变。
*/
let iosEntryUrl: string | null = null;
/** 清除 JSSDK 配置缓存(路由切换后须重新 wx.config) */ /** 清除 JSSDK 配置缓存(路由切换后须重新 wx.config) */
export function resetJssdkConfig(): void { export function resetJssdkConfig(): void {
configured = false; configured = false;
@@ -42,25 +50,36 @@ function signUrlChanged(prev: string | null, current: string): boolean {
return prev !== current; return prev !== current;
} }
/** 记录最近一次签名 URL;SPA 路由或 ?step= 变化时须重新 wx.config */ function clearLegacySignUrlCache(): void {
export function captureIosJssdkEntryUrl(): void { try {
if (typeof window === 'undefined') return; sessionStorage.removeItem(LEGACY_SIGN_URL_CACHE_KEY);
if (!isWechatBrowser() || isWechatDevTools()) return; } catch {
const current = normalizeJssdkPageUrl(window.location.href); /* ignore */
const existing = sessionStorage.getItem(SIGN_URL_CACHE_KEY);
if (!existing) {
sessionStorage.setItem(SIGN_URL_CACHE_KEY, current);
return;
}
if (signUrlChanged(existing, current)) {
sessionStorage.setItem(SIGN_URL_CACHE_KEY, current);
resetJssdkConfig();
} }
} }
/** 获取参与 JSSDK 签名的 URL(始终为当前页完整 URL,含 query) */ /**
* 捕获 iOS 微信入场 URL(每个 document 生命周期只记一次)。
* 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);
}
/** 获取参与 JSSDK 签名的 URL;iOS 微信内固定为本次入场 URL */
export function getJssdkSignUrl(rawUrl?: string): string { export function getJssdkSignUrl(rawUrl?: string): string {
return normalizeJssdkPageUrl(rawUrl ?? (typeof window !== 'undefined' ? window.location.href : '')); const current = normalizeJssdkPageUrl(
rawUrl ?? (typeof window !== 'undefined' ? window.location.href : ''),
);
if (typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools()) {
captureIosJssdkEntryUrl();
if (iosEntryUrl) return iosEntryUrl;
}
return current;
} }
function isJssdkDebugEnabled(): boolean { function isJssdkDebugEnabled(): boolean {
@@ -76,6 +95,8 @@ export function stripOAuthParamsFromLocation(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (!url.searchParams.has('code') && !url.searchParams.has('state')) return; if (!url.searchParams.has('code') && !url.searchParams.has('state')) return;
// iOS:须先锁定入场 URL,再 replaceState;否则签名 URL 与微信内部 URL 不一致
captureIosJssdkEntryUrl();
url.searchParams.delete('code'); url.searchParams.delete('code');
url.searchParams.delete('state'); url.searchParams.delete('state');
const query = url.searchParams.toString(); const query = url.searchParams.toString();
@@ -126,7 +147,8 @@ export async function initWechatJssdk(options: {
}): Promise<void> { }): Promise<void> {
captureIosJssdkEntryUrl(); captureIosJssdkEntryUrl();
const { apiBase, clientApp, getAccessToken } = options; const { apiBase, clientApp, getAccessToken } = options;
const pageUrl = options.url ?? getJssdkSignUrl(); // iOS 忽略调用方传入的「当前页」URL,强制入场 URL,避免登录后 SPA 到首页签错名
const pageUrl = getJssdkSignUrl(options.url);
await loadScript(); await loadScript();
if (!window.wx) throw new Error('微信 JSSDK 不可用'); if (!window.wx) throw new Error('微信 JSSDK 不可用');
@@ -164,7 +186,7 @@ export async function ensureJssdkReady(options: {
jsApiList?: string[]; jsApiList?: string[];
}): Promise<void> { }): Promise<void> {
captureIosJssdkEntryUrl(); captureIosJssdkEntryUrl();
const pageUrl = options.url ?? getJssdkSignUrl(); const pageUrl = getJssdkSignUrl(options.url);
if (configuredUrl && signUrlChanged(configuredUrl, pageUrl)) { if (configuredUrl && signUrlChanged(configuredUrl, pageUrl)) {
resetJssdkConfig(); resetJssdkConfig();
} }