import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios"; // API URL 동적 설정 - 환경변수 우선 사용 const getApiBaseUrl = (): string => { if (process.env.NEXT_PUBLIC_API_URL) { return process.env.NEXT_PUBLIC_API_URL; } if (typeof window !== "undefined") { const currentHost = window.location.hostname; const currentPort = window.location.port; if (currentHost === "v1.vexplor.com") { return "https://api.vexplor.com/api"; } if ( (currentHost === "localhost" || currentHost === "127.0.0.1") && (currentPort === "9771" || currentPort === "3000") ) { return "http://localhost:8080/api"; } } return "http://localhost:8080/api"; }; export const API_BASE_URL = getApiBaseUrl(); // 이미지 URL을 완전한 URL로 변환하는 함수 export const getFullImageUrl = (imagePath: string): string => { if (!imagePath) return ""; if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { return imagePath; } if (imagePath.startsWith("/uploads")) { if (typeof window !== "undefined") { const currentHost = window.location.hostname; if (currentHost === "v1.vexplor.com") { return `https://api.vexplor.com${imagePath}`; } if (currentHost === "localhost" || currentHost === "127.0.0.1") { return `http://localhost:8080${imagePath}`; } } const baseUrl = API_BASE_URL.replace(/\/api$/, ""); if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { return `${baseUrl}${imagePath}`; } return imagePath; } return imagePath; }; // ============================================ // JWT 토큰 관리 유틸리티 // ============================================ const TokenManager = { getToken: (): string | null => { if (typeof window !== "undefined") { return localStorage.getItem("authToken"); } return null; }, setToken: (token: string): void => { if (typeof window !== "undefined") { localStorage.setItem("authToken", token); } }, removeToken: (): void => { if (typeof window !== "undefined") { localStorage.removeItem("authToken"); } }, isTokenExpired: (token: string): boolean => { try { const payload = JSON.parse(atob(token.split(".")[1])); return payload.exp * 1000 < Date.now(); } catch { return true; } }, // 만료 30분 전부터 갱신 대상 isTokenExpiringSoon: (token: string): boolean => { try { const payload = JSON.parse(atob(token.split(".")[1])); const expiryTime = payload.exp * 1000; const currentTime = Date.now(); const thirtyMinutes = 30 * 60 * 1000; return expiryTime - currentTime < thirtyMinutes && expiryTime > currentTime; } catch { return false; } }, getTimeUntilExpiry: (token: string): number => { try { const payload = JSON.parse(atob(token.split(".")[1])); return payload.exp * 1000 - Date.now(); } catch { return 0; } }, }; // ============================================ // 토큰 갱신 로직 (중복 요청 방지) // ============================================ let isRefreshing = false; let refreshSubscribers: Array<(token: string) => void> = []; let failedRefreshSubscribers: Array<(error: Error) => void> = []; // 갱신 대기 중인 요청들에게 새 토큰 전달 const onTokenRefreshed = (newToken: string) => { refreshSubscribers.forEach((callback) => callback(newToken)); refreshSubscribers = []; failedRefreshSubscribers = []; }; // 갱신 실패 시 대기 중인 요청들에게 에러 전달 const onRefreshFailed = (error: Error) => { failedRefreshSubscribers.forEach((callback) => callback(error)); refreshSubscribers = []; failedRefreshSubscribers = []; }; // 갱신 완료 대기 Promise 등록 const waitForTokenRefresh = (): Promise => { return new Promise((resolve, reject) => { refreshSubscribers.push(resolve); failedRefreshSubscribers.push(reject); }); }; const refreshToken = async (): Promise => { try { const currentToken = TokenManager.getToken(); if (!currentToken) { return null; } const response = await axios.post( `${API_BASE_URL}/auth/refresh`, {}, { headers: { Authorization: `Bearer ${currentToken}`, }, }, ); if (response.data?.success && response.data?.data?.token) { const newToken = response.data.data.token; TokenManager.setToken(newToken); return newToken; } return null; } catch { return null; } }; // ============================================ // 자동 토큰 갱신 (백그라운드) // ============================================ let tokenRefreshTimer: ReturnType | null = null; const startAutoRefresh = (): void => { if (typeof window === "undefined") return; if (tokenRefreshTimer) { clearInterval(tokenRefreshTimer); } // 5분마다 토큰 상태 확인 (기존 10분 → 5분으로 단축) tokenRefreshTimer = setInterval( async () => { const token = TokenManager.getToken(); if (token && TokenManager.isTokenExpiringSoon(token)) { await refreshToken(); } }, 5 * 60 * 1000, ); // 페이지 로드 시 즉시 확인 const token = TokenManager.getToken(); if (token && TokenManager.isTokenExpiringSoon(token)) { refreshToken(); } }; // 페이지 포커스 복귀 시 토큰 갱신 체크 (백그라운드 탭 throttle 대응) const setupVisibilityRefresh = (): void => { if (typeof window === "undefined") return; document.addEventListener("visibilitychange", () => { if (!document.hidden) { const token = TokenManager.getToken(); if (!token) return; if (TokenManager.isTokenExpired(token)) { // 만료됐으면 갱신 시도 refreshToken().then((newToken) => { if (!newToken) { redirectToLogin(); } }); } else if (TokenManager.isTokenExpiringSoon(token)) { refreshToken(); } } }); }; // 사용자 활동 감지 기반 갱신 const setupActivityBasedRefresh = (): void => { if (typeof window === "undefined") return; let lastActivityCheck = Date.now(); const activityThreshold = 5 * 60 * 1000; // 5분 const handleActivity = (): void => { const now = Date.now(); if (now - lastActivityCheck > activityThreshold) { const token = TokenManager.getToken(); if (token && TokenManager.isTokenExpiringSoon(token)) { refreshToken(); } lastActivityCheck = now; } }; ["click", "keydown"].forEach((event) => { let throttleTimer: ReturnType | null = null; window.addEventListener( event, () => { if (!throttleTimer) { throttleTimer = setTimeout(() => { handleActivity(); throttleTimer = null; }, 2000); } }, { passive: true }, ); }); }; // 로그인 페이지 리다이렉트 (중복 방지) let isRedirecting = false; const redirectToLogin = (): void => { if (typeof window === "undefined") return; if (isRedirecting) return; if (window.location.pathname === "/login") return; isRedirecting = true; TokenManager.removeToken(); window.location.href = "/login"; }; // 클라이언트 사이드에서 자동 갱신 시작 if (typeof window !== "undefined") { startAutoRefresh(); setupVisibilityRefresh(); setupActivityBasedRefresh(); } // ============================================ // Axios 인스턴스 생성 // ============================================ export const apiClient = axios.create({ baseURL: API_BASE_URL, timeout: 30000, headers: { "Content-Type": "application/json", }, withCredentials: true, }); // ============================================ // 요청 인터셉터 // ============================================ apiClient.interceptors.request.use( async (config: InternalAxiosRequestConfig) => { const token = TokenManager.getToken(); if (token) { if (!TokenManager.isTokenExpired(token)) { // 유효한 토큰 → 그대로 사용 config.headers.Authorization = `Bearer ${token}`; } else { // 만료된 토큰 → 갱신 시도 후 사용 const newToken = await refreshToken(); if (newToken) { config.headers.Authorization = `Bearer ${newToken}`; } // 갱신 실패해도 요청은 보냄 (401 응답 인터셉터에서 처리) } } // FormData 요청 시 Content-Type 자동 처리 if (config.data instanceof FormData) { delete config.headers["Content-Type"]; } // 언어 정보를 쿼리 파라미터에 추가 (GET 요청) if (config.method?.toUpperCase() === "GET") { let currentLang = "KR"; if (typeof window !== "undefined") { if ((window as unknown as { __GLOBAL_USER_LANG?: string }).__GLOBAL_USER_LANG) { currentLang = (window as unknown as { __GLOBAL_USER_LANG: string }).__GLOBAL_USER_LANG; } else { const storedLocale = localStorage.getItem("userLocale"); if (storedLocale) { currentLang = storedLocale; } } } if (config.params) { config.params.userLang = currentLang; } else { config.params = { userLang: currentLang }; } } return config; }, (error) => { return Promise.reject(error); }, ); // ============================================ // 응답 인터셉터 // ============================================ apiClient.interceptors.response.use( (response: AxiosResponse) => { // 백엔드에서 보내주는 새로운 토큰 처리 const newToken = response.headers["x-new-token"]; if (newToken) { TokenManager.setToken(newToken); } return response; }, async (error: AxiosError) => { const status = error.response?.status; const url = error.config?.url; // 409 에러 (중복 데이터) - 조용하게 처리 if (status === 409) { if (url?.includes("/check-duplicate") || url?.includes("/dataflow-diagrams")) { return Promise.reject(error); } return Promise.reject(error); } // 채번 규칙 미리보기 API 실패는 조용하게 처리 if (url?.includes("/numbering-rules/") && url?.includes("/preview")) { return Promise.reject(error); } // 401 에러 처리 (핵심 개선) if (status === 401 && typeof window !== "undefined") { const errorData = error.response?.data as { error?: { code?: string } }; const errorCode = errorData?.error?.code; const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; // 이미 재시도한 요청이면 로그인으로 if (originalRequest?._retry) { redirectToLogin(); return Promise.reject(error); } // 토큰 만료 에러 → 갱신 후 재시도 if (errorCode === "TOKEN_EXPIRED" && originalRequest) { if (!isRefreshing) { isRefreshing = true; originalRequest._retry = true; try { const newToken = await refreshToken(); if (newToken) { isRefreshing = false; onTokenRefreshed(newToken); originalRequest.headers.Authorization = `Bearer ${newToken}`; return apiClient.request(originalRequest); } else { isRefreshing = false; onRefreshFailed(new Error("토큰 갱신 실패")); redirectToLogin(); return Promise.reject(error); } } catch (refreshError) { isRefreshing = false; onRefreshFailed(refreshError as Error); redirectToLogin(); return Promise.reject(error); } } else { // 다른 요청이 이미 갱신 중 → 갱신 완료 대기 후 재시도 try { const newToken = await waitForTokenRefresh(); originalRequest._retry = true; originalRequest.headers.Authorization = `Bearer ${newToken}`; return apiClient.request(originalRequest); } catch { return Promise.reject(error); } } } // TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로 redirectToLogin(); } return Promise.reject(error); }, ); // ============================================ // 공통 타입 및 헬퍼 // ============================================ export interface ApiResponse { success: boolean; data?: T; message?: string; errorCode?: string; } export interface UserInfo { userId: string; userName: string; deptName?: string; companyCode?: string; userType?: string; userTypeName?: string; email?: string; photo?: string; locale?: string; isAdmin?: boolean; } export const getCurrentUser = async (): Promise> => { try { const response = await apiClient.get("/auth/me"); return response.data; } catch (error: any) { return { success: false, message: error.response?.data?.message || error.message || "사용자 정보를 가져올 수 없습니다.", errorCode: error.response?.data?.errorCode, }; } }; export const apiCall = async ( method: "GET" | "POST" | "PUT" | "DELETE", url: string, data?: unknown, ): Promise> => { try { const response = await apiClient.request({ method, url, data, }); return response.data; } catch (error: unknown) { const axiosError = error as AxiosError; return { success: false, message: (axiosError.response?.data as { message?: string })?.message || axiosError.message || "알 수 없는 오류가 발생했습니다.", errorCode: (axiosError.response?.data as { errorCode?: string })?.errorCode, }; } };