e4e9eb2169
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>
191 lines
6.7 KiB
TypeScript
191 lines
6.7 KiB
TypeScript
import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types';
|
||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||
import { isWechatEnv, weixinSdk } from './weixin';
|
||
import { markScanWarmupAfterAuth } from './shop-scan-auth';
|
||
import { request, saveWechatSession, type ShopSessionPayload } from './api';
|
||
|
||
export type ShopAccountProfile = {
|
||
id: string;
|
||
storeId: string;
|
||
name: string;
|
||
phone: string;
|
||
/** 是否已绑定微信(来自 /shop/auth/me account.hasWechat) */
|
||
hasWechat?: boolean;
|
||
wxOpenId?: string | null;
|
||
store?: { name: string };
|
||
};
|
||
|
||
/** 微信未绑定门店账号时的登录提示 */
|
||
export const SHOP_WX_NEED_PHONE_LOGIN_MSG = '请先用手机号登录';
|
||
|
||
const SHOP_WX_LOGIN_HINT_KEY = 'shop_wx_login_hint';
|
||
|
||
export function isShopWechatUnboundError(message: string): boolean {
|
||
return message.includes('首次登录') || message.includes('手机验证码') || message.includes('请先用手机号');
|
||
}
|
||
|
||
export function formatShopWechatError(e: unknown): string {
|
||
const text = e instanceof Error ? e.message : '微信登录失败';
|
||
if (isShopWechatUnboundError(text)) return SHOP_WX_NEED_PHONE_LOGIN_MSG;
|
||
return text;
|
||
}
|
||
|
||
/** OAuth 在启动层失败时暂存提示,供登录页展示 */
|
||
export function stashShopWechatLoginHint(message: string): void {
|
||
try {
|
||
sessionStorage.setItem(SHOP_WX_LOGIN_HINT_KEY, message);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
export function consumeShopWechatLoginHint(): string | null {
|
||
try {
|
||
const hint = sessionStorage.getItem(SHOP_WX_LOGIN_HINT_KEY);
|
||
if (hint) sessionStorage.removeItem(SHOP_WX_LOGIN_HINT_KEY);
|
||
return hint;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||
return request<ClientRuntimeConfig>('SHOP_H5', '/common/client-config');
|
||
}
|
||
|
||
export async function fetchShopAccount(): Promise<ShopAccountProfile> {
|
||
const me = await request<{
|
||
id?: string;
|
||
storeId?: string;
|
||
name?: string;
|
||
phone?: string;
|
||
account?: {
|
||
id?: string;
|
||
name?: string;
|
||
phone?: string;
|
||
hasWechat?: boolean;
|
||
};
|
||
store?: { name?: string } | null;
|
||
}>('SHOP_H5', '/shop/auth/me');
|
||
return {
|
||
id: String(me.account?.id ?? me.id ?? ''),
|
||
storeId: String(me.storeId ?? ''),
|
||
name: String(me.account?.name ?? me.name ?? ''),
|
||
phone: String(me.account?.phone ?? me.phone ?? ''),
|
||
hasWechat: !!me.account?.hasWechat,
|
||
store: me.store?.name ? { name: String(me.store.name) } : undefined,
|
||
};
|
||
}
|
||
|
||
export function needsWechatAuth(
|
||
profile: ShopAccountProfile | null,
|
||
config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null,
|
||
): boolean {
|
||
if (config && !isWxAuthorizeEnabled(config)) return false;
|
||
const unbound = !!profile && !(profile.hasWechat || profile.wxOpenId);
|
||
return isWechatEnv() && unbound;
|
||
}
|
||
|
||
export async function checkNeedsWechatAuth(profile: ShopAccountProfile | null): Promise<boolean> {
|
||
const config = await fetchClientConfig();
|
||
return needsWechatAuth(profile, config);
|
||
}
|
||
|
||
export function sessionFromWechatLogin(result: WechatLoginResult): ShopSessionPayload | null {
|
||
if (!result.accessToken || !result.refreshToken) return null;
|
||
const store = result.store as Record<string, unknown> | undefined;
|
||
const stores = Array.isArray((result as { stores?: unknown }).stores)
|
||
? ((result as { stores: ShopSessionPayload['stores'] }).stores)
|
||
: undefined;
|
||
const account = (result as { account?: ShopSessionPayload['account'] }).account;
|
||
return {
|
||
accessToken: result.accessToken,
|
||
refreshToken: result.refreshToken,
|
||
stores,
|
||
account,
|
||
selectedStoreId: (result as { selectedStoreId?: string }).selectedStoreId,
|
||
store: store
|
||
? {
|
||
id: String(store.id ?? account?.id ?? ''),
|
||
storeId: String(store.storeId ?? ''),
|
||
name: String(store.name ?? account?.name ?? ''),
|
||
phone: String(store.phone ?? account?.phone ?? ''),
|
||
storeName: String(store.storeName ?? store.name ?? ''),
|
||
isPrimary: account?.isPrimary,
|
||
stores,
|
||
}
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
/** 处理微信登录/绑定结果,写入 7 天免登录 session */
|
||
export function handleShopWechatLoginResult(result: WechatLoginResult): ShopSessionPayload | null {
|
||
const session = sessionFromWechatLogin(result);
|
||
if (!session) return null;
|
||
saveWechatSession(session);
|
||
return session;
|
||
}
|
||
|
||
/** @deprecated 使用 handleShopWechatLoginResult */
|
||
export function saveShopWechatAuth(result: WechatLoginResult): boolean {
|
||
return !!handleShopWechatLoginResult(result);
|
||
}
|
||
|
||
export async function handleShopWechatCallback(): Promise<WechatLoginResult | null> {
|
||
if (!isWechatEnv()) return null;
|
||
const config = await fetchClientConfig();
|
||
if (!isWxAuthorizeEnabled(config)) return null;
|
||
return weixinSdk.handleOAuthCallback();
|
||
}
|
||
|
||
let oauthCallbackInflight: Promise<WechatLoginResult | null> | null = null;
|
||
|
||
/** 全局单例:避免 Context 与页面 effect 重复消费 OAuth code */
|
||
export async function handleShopWechatCallbackOnce(): Promise<WechatLoginResult | null> {
|
||
if (!isWechatEnv()) return null;
|
||
const params = new URLSearchParams(window.location.search);
|
||
if (!params.get('code')) return null;
|
||
if (oauthCallbackInflight) return oauthCallbackInflight;
|
||
oauthCallbackInflight = handleShopWechatCallback().finally(() => {
|
||
oauthCallbackInflight = null;
|
||
});
|
||
return oauthCallbackInflight;
|
||
}
|
||
|
||
/**
|
||
* 微信一键登录(已绑定微信的门店账号免验证码)。
|
||
* 返回 session = 已登录;void = 已跳转授权页等待回调。
|
||
*/
|
||
export async function loginShopWithWechat(): Promise<ShopSessionPayload | null | void> {
|
||
const config = await fetchClientConfig();
|
||
if (!isWxAuthorizeEnabled(config)) return null;
|
||
if (!isWechatEnv()) {
|
||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||
}
|
||
markScanWarmupAfterAuth();
|
||
const result = await weixinSdk.login();
|
||
if (result) return handleShopWechatLoginResult(result);
|
||
}
|
||
|
||
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
|
||
export async function bindShopWechatAfterSmsLogin(session?: ShopSessionPayload): Promise<'skipped' | void> {
|
||
const config = await fetchClientConfig();
|
||
if (!isWxAuthorizeEnabled(config)) return 'skipped';
|
||
if (!isWechatEnv()) return 'skipped';
|
||
// 已绑定则勿再 OAuth:每次 OAuth 回跳都会重置 iOS JSSDK 入场 URL,易导致扫码失败
|
||
if (session?.account?.hasWechat) return 'skipped';
|
||
markScanWarmupAfterAuth();
|
||
await weixinSdk.login();
|
||
}
|
||
|
||
export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
|
||
const config = await fetchClientConfig();
|
||
if (!isWxAuthorizeEnabled(config)) return;
|
||
if (!isWechatEnv()) {
|
||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||
}
|
||
markScanWarmupAfterAuth();
|
||
return weixinSdk.login();
|
||
}
|