import axios, { type InternalAxiosRequestConfig } from 'axios' export const apiClient = axios.create({ baseURL: '/api', timeout: 10000, }) // ---- refresh retry lock ---- let isRefreshing = false let pendingRequests: Array<(token: string) => void> = [] function subscribePendingRequests(token: string) { pendingRequests.forEach((cb) => cb(token)) pendingRequests.length = 0 } function addPendingRequest(callback: (token: string) => void) { pendingRequests.push(callback) } async function refreshTokenAndRetry(isAdmin = false): Promise { const refreshTokenKey = isAdmin ? 'admin_refresh_token' : 'refresh_token' const refreshToken = localStorage.getItem(refreshTokenKey) if (!refreshToken) { throw new Error('no refresh token') } const endpoint = isAdmin ? '/admin/auth/refresh' : '/auth/refresh' // use raw axios (not apiClient) to avoid interceptor recursion const { data } = await axios.post(endpoint, { refresh_token: refreshToken }) const newAccessToken = data.data.access_token const newRefreshToken = data.data.refresh_token const accessKey = isAdmin ? 'admin_access_token' : 'access_token' localStorage.setItem(accessKey, newAccessToken) localStorage.setItem(refreshTokenKey, newRefreshToken) return newAccessToken } // clear user tokens function clearUserTokens() { ;['access_token', 'refresh_token', 'user_id', 'phone', 'nickname', 'avatar_url', 'realname_status'] .forEach((k) => localStorage.removeItem(k)) } // clear admin tokens function clearAdminTokens() { ;['admin_access_token', 'admin_refresh_token', 'admin_id', 'admin_username'] .forEach((k) => localStorage.removeItem(k)) } // ---- Request interceptor ---- apiClient.interceptors.request.use((config) => { const url = config.url || '' const tokenKey = url.startsWith('/admin') ? 'admin_access_token' : 'access_token' const token = localStorage.getItem(tokenKey) if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // ---- Response interceptor ---- apiClient.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } if (error?.response?.status !== 401 || originalRequest._retry) { return Promise.reject(error) } const requestUrl = originalRequest.url || '' const isAdminRequest = requestUrl.startsWith('/admin') // exclude refresh endpoints themselves to avoid dead loop if (requestUrl.endsWith('/auth/refresh') || requestUrl.endsWith('/admin/auth/refresh')) { if (isAdminRequest) clearAdminTokens() else clearUserTokens() if (isAdminRequest && !window.location.pathname.startsWith('/admin/login')) { window.location.assign('/admin/login') } else if (!isAdminRequest) { const currentPath = window.location.pathname + window.location.search if (!currentPath.startsWith('/m/login') && !currentPath.startsWith('/login')) { const redirect = encodeURIComponent(currentPath) const loginPath = currentPath.startsWith('/m') ? '/m/login' : '/login' window.location.assign(`${loginPath}?redirect=${redirect}`) } } return Promise.reject(error) } // if already refreshing, queue up if (isRefreshing) { return new Promise((resolve) => { addPendingRequest((newToken: string) => { originalRequest.headers.Authorization = `Bearer ${newToken}` resolve(apiClient(originalRequest)) }) }) } // attempt refresh isRefreshing = true try { const newToken = await refreshTokenAndRetry(isAdminRequest) subscribePendingRequests(newToken) originalRequest._retry = true originalRequest.headers.Authorization = `Bearer ${newToken}` return apiClient(originalRequest) } catch { // refresh failed, clear tokens and redirect to login subscribePendingRequests('') // let queued requests fail if (isAdminRequest) { clearAdminTokens() if (!window.location.pathname.startsWith('/admin/login')) { window.location.assign('/admin/login') } } else { clearUserTokens() const currentPath = window.location.pathname + window.location.search if (!currentPath.startsWith('/m/login') && !currentPath.startsWith('/login')) { const redirect = encodeURIComponent(currentPath) const loginPath = currentPath.startsWith('/m') ? '/m/login' : '/login' window.location.assign(`${loginPath}?redirect=${redirect}`) } } return Promise.reject(error) } finally { isRefreshing = false } } )