import { reportApiError } from '@dukang/client-logging'; export const apiBase = '/api/v1'; const CLIENT_APP = 'SHOP_H5'; export type ShopStoreOption = { storeId: string; name: string; status: string; district?: string; address?: string; }; export type StoreSessionStore = { id: string; storeId: string; name: string; phone: string; storeName: string; isPrimary?: boolean; stores?: ShopStoreOption[]; }; export type StoreProfile = { id: string; storeId: string; name: string; phone: string; status?: string; isPrimary?: boolean; account?: { id: string; name: string; phone: string; isPrimary: boolean; hasWechat?: boolean; }; store?: ShopStoreOption | { id: string; name: string } | null; stores?: ShopStoreOption[]; }; export type ShopSessionPayload = { accessToken: string; refreshToken: string; store?: StoreSessionStore; stores?: ShopStoreOption[]; account?: StoreProfile['account']; selectedStoreId?: string; }; const ACCESS_TOKEN = 'accessToken'; const REFRESH_TOKEN = 'refreshToken'; const LAST_PHONE = 'shopLastPhone'; const STORE_PROFILE = 'shopStoreProfile'; const SESSION_EXPIRES_AT = 'shopSessionExpiresAt'; export const SHOP_WX_BOUND = 'shopWxBound'; /** 手机号或微信验证通过后的免登录时长 */ export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; const AUTH_RECOVERY_EXEMPT_PATHS = [ '/shop/auth/token/refresh', '/shop/auth/sms/send', '/shop/auth/login/sms', '/shop/auth/login/wechat', ]; export function getLastPhone() { return localStorage.getItem(LAST_PHONE) ?? ''; } export function getStoreProfile(): StoreSessionStore | null { try { const raw = localStorage.getItem(STORE_PROFILE); return raw ? (JSON.parse(raw) as StoreSessionStore) : null; } catch { return null; } } export function hasShopWxSession() { return localStorage.getItem(SHOP_WX_BOUND) === '1'; } export function isShopSessionExpired() { const raw = localStorage.getItem(SESSION_EXPIRES_AT); if (!raw) return false; return Date.now() > Number(raw); } export function touchShopSession() { if (!localStorage.getItem(REFRESH_TOKEN)) return; localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS)); } export function saveAuth(data: ShopSessionPayload) { localStorage.setItem(ACCESS_TOKEN, data.accessToken); if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken); if (data.store) { const profile: StoreSessionStore = { ...data.store, stores: data.stores ?? data.store.stores, isPrimary: data.account?.isPrimary ?? data.store.isPrimary, }; localStorage.setItem(STORE_PROFILE, JSON.stringify(profile)); if (data.store.phone) localStorage.setItem(LAST_PHONE, data.store.phone); } else if (data.account) { localStorage.setItem( STORE_PROFILE, JSON.stringify({ id: data.account.id, storeId: '', name: data.account.name, phone: data.account.phone, storeName: '', isPrimary: data.account.isPrimary, stores: data.stores ?? [], } satisfies StoreSessionStore), ); localStorage.setItem(LAST_PHONE, data.account.phone); } } /** 手机号验证成功后写入 7 天免验证码会话 */ export function saveRememberedSession(data: ShopSessionPayload) { saveAuth(data); localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS)); } /** 微信登录/绑定成功后写入 7 天免登录 session */ export function saveWechatSession(data: ShopSessionPayload) { saveRememberedSession(data); localStorage.setItem(SHOP_WX_BOUND, '1'); } export function clearAuth(options?: { keepProfile?: boolean }) { localStorage.removeItem(ACCESS_TOKEN); localStorage.removeItem(REFRESH_TOKEN); localStorage.removeItem(SESSION_EXPIRES_AT); localStorage.removeItem(SHOP_WX_BOUND); if (!options?.keepProfile) { localStorage.removeItem(STORE_PROFILE); localStorage.removeItem(LAST_PHONE); } } export function isLoggedIn() { return !!localStorage.getItem(ACCESS_TOKEN); } function profileFromMe(me: StoreProfile): StoreSessionStore { const selected = me.store && 'storeId' in me.store ? me.store : me.store && 'id' in me.store ? { storeId: String((me.store as { id: string }).id), name: me.store.name } : null; return { id: me.account?.id ?? me.id, storeId: selected?.storeId ?? me.storeId ?? '', name: me.account?.name ?? me.name, phone: me.account?.phone ?? me.phone, storeName: selected?.name ?? '', isPrimary: me.account?.isPrimary ?? me.isPrimary, stores: me.stores ?? [], }; } export function needsStoreSelection(session: { store?: StoreSessionStore | null; stores?: ShopStoreOption[]; selectedStoreId?: string; }): boolean { const storeId = session.store?.storeId || session.selectedStoreId || ''; const stores = session.stores ?? session.store?.stores ?? []; if (stores.length > 1 && !storeId) return true; if (!storeId && stores.length !== 1) return true; return false; } export async function selectStore(storeId: string): Promise { const data = await requestWithAuthRetry('/shop/auth/select-store', { method: 'POST', body: JSON.stringify({ storeId }), }); saveAuth(data); touchShopSession(); return data; } async function rawRequest( path: string, options: RequestInit = {}, token?: string | null, ): Promise { const headers: Record = { 'Content-Type': 'application/json', 'X-Client-App': CLIENT_APP, ...(options.headers as Record), }; const authToken = token ?? localStorage.getItem(ACCESS_TOKEN); if (authToken) headers.Authorization = `Bearer ${authToken}`; const res = await fetch(`${apiBase}${path}`, { ...options, headers }); let json: { code: number; message?: string; data?: T; reason?: string }; try { json = await res.json(); } catch { const err = new Error(res.ok ? '接口返回异常' : `网络异常(HTTP ${res.status})`) as Error & { status?: number; }; err.status = res.status; throw err; } if (json.code !== 0) { const err = new Error(json.message || '请求失败') as Error & { status?: number; reason?: string; }; err.status = res.status >= 500 ? res.status : json.code; err.reason = json.reason; if (json.code === 400) { reportApiError( { apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) }, { message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' }, ); } throw err; } return json.data as T; } async function refreshSession(): Promise { const refreshToken = localStorage.getItem(REFRESH_TOKEN); if (!refreshToken) return null; try { const data = await rawRequest( '/shop/auth/token/refresh', { method: 'POST', body: JSON.stringify({ refreshToken }), }, null, ); saveAuth(data); touchShopSession(); return data; } catch { return null; } } async function requestWithAuthRetry( path: string, options: RequestInit = {}, retried = false, ): Promise { try { return await rawRequest(path, options); } catch (e) { const err = e as Error & { status?: number; reason?: string }; // 账号停用 / 门店关闭 / 合伙人绑定失效:强制退出登录 if (err.reason === 'ACCOUNT_DISABLED') { clearAuth(); if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { window.location.replace('/login?disabled=1'); } throw e; } const canRecover = err.status === 401 && !retried && !AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p)); if (!canRecover) throw e; const refreshed = await refreshSession(); if (!refreshed) { clearAuth(); throw e; } return requestWithAuthRetry(path, options, true); } } export async function request(clientApp: string, path: string, options: RequestInit = {}): Promise { void clientApp; return requestWithAuthRetry(path, options); } export async function ensureSession(): Promise<{ authenticated: boolean; store: StoreSessionStore | null; needsSelectStore: boolean; }> { if (!isLoggedIn()) { return { authenticated: false, store: null, needsSelectStore: false }; } if (isShopSessionExpired()) { clearAuth({ keepProfile: true }); return { authenticated: false, store: getStoreProfile(), needsSelectStore: false }; } try { const me = await rawRequest('/shop/auth/me'); const store = profileFromMe(me); saveAuth({ accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '', refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '', store, stores: me.stores, account: me.account, }); touchShopSession(); return { authenticated: true, store, needsSelectStore: needsStoreSelection({ store, stores: me.stores }), }; } catch (e) { const err = e as Error & { status?: number; reason?: string }; if (err.reason === 'ACCOUNT_DISABLED') { clearAuth(); if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { window.location.replace('/login?disabled=1'); } return { authenticated: false, store: null, needsSelectStore: false }; } if (err.status === 401) { const refreshed = await refreshSession(); if (refreshed) { return { authenticated: true, store: refreshed.store ?? getStoreProfile(), needsSelectStore: needsStoreSelection(refreshed), }; } clearAuth({ keepProfile: true }); return { authenticated: false, store: getStoreProfile(), needsSelectStore: false }; } throw e; } }