import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types'; import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk'; import { showPartnerToast } from './toast'; export const apiBase = '/api/v1'; const CLIENT_APP = 'PARTNER_H5'; const ACCESS_TOKEN = 'accessToken'; const REFRESH_TOKEN = 'refreshToken'; const LAST_PHONE = 'partnerLastPhone'; const PARTNER_PROFILE = 'partnerProfile'; const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt'; export const PARTNER_WX_BOUND = 'partnerWxBound'; /** 手机号或微信验证通过后的免登录时长 */ export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; const AUTH_RECOVERY_EXEMPT_PATHS = [ '/partner/auth/token/refresh', '/partner/auth/sms/send', '/partner/auth/login/sms', '/partner/auth/login/wechat', ]; export type PartnerSessionProfile = Pick< PartnerMe, | 'id' | 'name' | 'phone' | 'companyName' | 'isPrimary' | 'permissions' | 'primaryAccountId' | 'primaryPhone' | 'primaryName' | 'hasWechat' | 'wxNickname' | 'wxAvatarUrl' | 'managedWarehouseId' | 'hasWarehouseAccess' > & { staffRole?: PartnerStaffRole; }; export type PartnerSessionPayload = { accessToken: string; refreshToken: string; partner?: PartnerSessionProfile; }; export type ApiRequestOptions = RequestInit & { /** 保留字段,兼容旧调用;错误提示由页面自行处理 */ silent?: boolean; }; export function getLastPhone() { return localStorage.getItem(LAST_PHONE) ?? ''; } export function getPartnerProfile(): PartnerSessionProfile | null { try { const raw = localStorage.getItem(PARTNER_PROFILE); return raw ? (JSON.parse(raw) as PartnerSessionProfile) : null; } catch { return null; } } export function hasPartnerWxSession() { return localStorage.getItem(PARTNER_WX_BOUND) === '1'; } export function isPartnerSessionExpired() { const raw = localStorage.getItem(SESSION_EXPIRES_AT); if (!raw) return false; return Date.now() > Number(raw); } export function touchPartnerSession() { if (!localStorage.getItem(REFRESH_TOKEN)) return; localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS)); } export function saveAuth(data: PartnerSessionPayload) { localStorage.setItem(ACCESS_TOKEN, data.accessToken); if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken); if (data.partner) { localStorage.setItem(PARTNER_PROFILE, JSON.stringify(data.partner)); localStorage.setItem(LAST_PHONE, data.partner.phone); } } /** 手机号验证成功后写入 7 天免验证码会话 */ export function saveRememberedSession(data: PartnerSessionPayload) { saveAuth(data); localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS)); } /** 微信登录/绑定成功后写入 7 天免登录 session */ export function saveWechatSession(data: PartnerSessionPayload) { saveRememberedSession(data); localStorage.setItem(PARTNER_WX_BOUND, '1'); } export function clearAuth(options?: { keepProfile?: boolean }) { localStorage.removeItem(ACCESS_TOKEN); localStorage.removeItem(REFRESH_TOKEN); localStorage.removeItem(SESSION_EXPIRES_AT); localStorage.removeItem(PARTNER_WX_BOUND); if (!options?.keepProfile) { localStorage.removeItem(PARTNER_PROFILE); localStorage.removeItem(LAST_PHONE); } } export function getToken() { return localStorage.getItem(ACCESS_TOKEN); } export function isLoggedIn() { return !!localStorage.getItem(ACCESS_TOKEN); } function profileFromMe(me: PartnerMe): PartnerSessionProfile { return { id: me.id, name: me.name, phone: me.phone, companyName: me.companyName, isPrimary: me.isPrimary, staffRole: me.staffRole ?? undefined, permissions: me.permissions, primaryAccountId: me.primaryAccountId, primaryPhone: me.primaryPhone, primaryName: me.primaryName, hasWechat: me.hasWechat, wxNickname: me.wxNickname, wxAvatarUrl: me.wxAvatarUrl, managedWarehouseId: me.managedWarehouseId, hasWarehouseAccess: me.hasWarehouseAccess, }; } 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 }); const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' })); if (json.code !== 0) { const err = new Error(json.message || '请求失败') as Error & { status?: number }; err.status = json.code === 401 ? 401 : json.code; 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( '/partner/auth/token/refresh', { method: 'POST', body: JSON.stringify({ refreshToken }), }, null, ); saveAuth(data); touchPartnerSession(); 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 }; 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({ keepProfile: true }); throw e; } return requestWithAuthRetry(path, options, true); } } export async function request( clientApp: string, path: string, options: ApiRequestOptions = {}, ): Promise { void clientApp; const { silent, ...fetchOptions } = options; try { return await requestWithAuthRetry(path, fetchOptions); } catch (e) { const err = e as Error & { status?: number }; const message = err.message || '请求失败'; if (err.status === 401) { if (localStorage.getItem(ACCESS_TOKEN)) { clearAuth({ keepProfile: true }); if (!silent) showPartnerToast(message, 'error'); if (typeof window !== 'undefined' && !isOnAppPath('/login')) { window.location.href = toAppPath('/login'); } } } else if (!silent) { showPartnerToast(message, 'error'); } throw e; } } export async function ensureSession(): Promise<{ authenticated: boolean; partner: PartnerSessionProfile | null }> { if (!isLoggedIn()) { return { authenticated: false, partner: null }; } if (isPartnerSessionExpired()) { clearAuth({ keepProfile: true }); return { authenticated: false, partner: getPartnerProfile() }; } try { const me = await rawRequest('/partner/me'); const partner = profileFromMe(me); saveAuth({ accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '', refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '', partner, }); touchPartnerSession(); return { authenticated: true, partner }; } catch (e) { const err = e as Error & { status?: number }; if (err.status === 401) { const refreshed = await refreshSession(); if (refreshed?.partner) { return { authenticated: true, partner: refreshed.partner }; } clearAuth({ keepProfile: true }); return { authenticated: false, partner: getPartnerProfile() }; } const cached = getPartnerProfile(); if (cached) return { authenticated: true, partner: cached }; throw e; } } /** @deprecated 使用 PartnerSessionPayload */ export type PartnerAuthPayload = PartnerSessionPayload;