import axios, { AxiosResponse, AxiosError } from "axios"; // API URL 동적 설정 - 환경변수 우선 사용 const getApiBaseUrl = (): string => { // 1. 환경변수가 있으면 우선 사용 if (process.env.NEXT_PUBLIC_API_URL) { return process.env.NEXT_PUBLIC_API_URL; } // 2. 클라이언트 사이드에서 동적 설정 if (typeof window !== "undefined") { const currentHost = window.location.hostname; const currentPort = window.location.port; const protocol = window.location.protocol; // 프로덕션 환경: v1.vexplor.com → api.vexplor.com if (currentHost === "v1.vexplor.com") { return "https://api.vexplor.com/api"; } // 로컬 개발환경: localhost:9771 또는 localhost:3000 → localhost:8080 if ( (currentHost === "localhost" || currentHost === "127.0.0.1") && (currentPort === "9771" || currentPort === "3000") ) { return "http://localhost:8080/api"; } } // 3. 기본값 return "http://localhost:8080/api"; }; export const API_BASE_URL = getApiBaseUrl(); // 이미지 URL을 완전한 URL로 변환하는 함수 // 주의: 모듈 로드 시점이 아닌 런타임에 hostname을 확인해야 SSR 문제 방지 export const getFullImageUrl = (imagePath: string): string => { // 빈 값 체크 if (!imagePath) return ""; // 이미 전체 URL인 경우 그대로 반환 if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { return imagePath; } // /uploads로 시작하는 상대 경로인 경우 API 서버 주소 추가 if (imagePath.startsWith("/uploads")) { // 런타임에 현재 hostname 확인 (SSR 시점이 아닌 클라이언트에서 실행될 때) if (typeof window !== "undefined") { const currentHost = window.location.hostname; // 프로덕션 환경: v1.vexplor.com → api.vexplor.com if (currentHost === "v1.vexplor.com") { return `https://api.vexplor.com${imagePath}`; } // 로컬 개발환경 if (currentHost === "localhost" || currentHost === "127.0.0.1") { return `http://localhost:8080${imagePath}`; } } // SSR 또는 알 수 없는 환경에서는 API_BASE_URL 사용 (fallback) const baseUrl = API_BASE_URL.replace("/api", ""); if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { return `${baseUrl}${imagePath}`; } // 최종 fallback 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; // 30분 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 refreshPromise: Promise | null = null; // 토큰 갱신 함수 const refreshToken = async (): Promise => { // 이미 갱신 중이면 기존 Promise 반환 if (isRefreshing && refreshPromise) { return refreshPromise; } isRefreshing = true; refreshPromise = (async () => { 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); console.log("[TokenManager] 토큰 갱신 성공"); return newToken; } return null; } catch (error) { console.error("[TokenManager] 토큰 갱신 실패:", error); return null; } finally { isRefreshing = false; refreshPromise = null; } })(); return refreshPromise; }; // 자동 토큰 갱신 타이머 let tokenRefreshTimer: NodeJS.Timeout | null = null; // 자동 토큰 갱신 시작 const startAutoRefresh = (): void => { if (typeof window === "undefined") return; // 기존 타이머 정리 if (tokenRefreshTimer) { clearInterval(tokenRefreshTimer); } // 10분마다 토큰 상태 확인 tokenRefreshTimer = setInterval(async () => { const token = TokenManager.getToken(); if (token && TokenManager.isTokenExpiringSoon(token)) { console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작..."); await refreshToken(); } }, 10 * 60 * 1000); // 10분 // 페이지 로드 시 즉시 확인 const token = TokenManager.getToken(); if (token && TokenManager.isTokenExpiringSoon(token)) { refreshToken(); } }; // 사용자 활동 감지 및 토큰 갱신 const setupActivityBasedRefresh = (): void => { if (typeof window === "undefined") return; let lastActivity = Date.now(); const activityThreshold = 5 * 60 * 1000; // 5분 const handleActivity = (): void => { const now = Date.now(); // 마지막 활동으로부터 5분 이상 지났으면 토큰 상태 확인 if (now - lastActivity > activityThreshold) { const token = TokenManager.getToken(); if (token && TokenManager.isTokenExpiringSoon(token)) { refreshToken(); } } lastActivity = now; }; // 사용자 활동 이벤트 감지 ["click", "keydown", "scroll", "mousemove"].forEach((event) => { // 너무 잦은 호출 방지를 위해 throttle 적용 let throttleTimer: NodeJS.Timeout | null = null; window.addEventListener(event, () => { if (!throttleTimer) { throttleTimer = setTimeout(() => { handleActivity(); throttleTimer = null; }, 1000); // 1초 throttle } }, { passive: true }); }); }; // 클라이언트 사이드에서 자동 갱신 시작 if (typeof window !== "undefined") { startAutoRefresh(); setupActivityBasedRefresh(); } // Axios 인스턴스 생성 export const apiClient = axios.create({ baseURL: API_BASE_URL, timeout: 30000, // 30초로 증가 (다중 커넥션 처리 시간 고려) headers: { "Content-Type": "application/json", }, withCredentials: true, // 쿠키 포함 }); // 요청 인터셉터 apiClient.interceptors.request.use( (config) => { // JWT 토큰 추가 const token = TokenManager.getToken(); if (token && !TokenManager.isTokenExpired(token)) { config.headers.Authorization = `Bearer ${token}`; } else if (token && TokenManager.isTokenExpired(token)) { console.warn("❌ 토큰이 만료되었습니다."); // 토큰 제거 if (typeof window !== "undefined") { localStorage.removeItem("authToken"); } } else { console.warn("⚠️ 토큰이 없습니다."); } // FormData 요청 시 Content-Type 자동 처리 if (config.data instanceof FormData) { delete config.headers["Content-Type"]; } // 언어 정보를 쿼리 파라미터에 추가 (GET 요청 시에만) if (config.method?.toUpperCase() === "GET") { // 우선순위: 전역 변수 > localStorage > 기본값 let currentLang = "KR"; // 기본값 if (typeof window !== "undefined") { // 1순위: 전역 변수에서 확인 if ((window as unknown as { __GLOBAL_USER_LANG?: string }).__GLOBAL_USER_LANG) { currentLang = (window as unknown as { __GLOBAL_USER_LANG: string }).__GLOBAL_USER_LANG; } // 2순위: localStorage에서 확인 (새 창이나 페이지 새로고침 시) 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) => { console.error("❌ API 요청 오류:", error); return Promise.reject(error); }, ); // 응답 인터셉터 apiClient.interceptors.response.use( (response: AxiosResponse) => { // 백엔드에서 보내주는 새로운 토큰 처리 const newToken = response.headers["x-new-token"]; if (newToken) { TokenManager.setToken(newToken); console.log("[TokenManager] 서버에서 새 토큰 수신, 저장 완료"); } return response; }, async (error: AxiosError) => { const status = error.response?.status; const url = error.config?.url; // 409 에러 (중복 데이터)는 조용하게 처리 if (status === 409) { // 중복 검사 API와 관계도 저장은 완전히 조용하게 처리 if (url?.includes("/check-duplicate") || url?.includes("/dataflow-diagrams")) { // 중복 검사와 관계도 중복 이름은 정상적인 비즈니스 로직이므로 콘솔 출력 없음 return Promise.reject(error); } // 일반 409 에러는 간단한 로그만 출력 console.warn("데이터 중복:", { url: url, message: (error.response?.data as { message?: string })?.message || "중복된 데이터입니다.", }); return Promise.reject(error); } // 채번 규칙 미리보기 API 실패는 조용하게 처리 (화면 로드 시 자주 발생) if (url?.includes("/numbering-rules/") && url?.includes("/preview")) { return Promise.reject(error); } // 다른 에러들은 기존처럼 상세 로그 출력 console.error("API 응답 오류:", { status: status, statusText: error.response?.statusText, url: url, data: error.response?.data, message: error.message, }); // 401 에러 처리 if (status === 401 && typeof window !== "undefined") { const errorData = error.response?.data as { error?: { code?: string } }; const errorCode = errorData?.error?.code; console.warn("[Auth] 401 오류 발생:", { url: url, errorCode: errorCode, token: TokenManager.getToken() ? "존재" : "없음", }); // 토큰 만료 에러인 경우 갱신 시도 const originalRequest = error.config as typeof error.config & { _retry?: boolean }; if (errorCode === "TOKEN_EXPIRED" && originalRequest && !originalRequest._retry) { console.log("[Auth] 토큰 만료, 갱신 시도..."); originalRequest._retry = true; try { const newToken = await refreshToken(); if (newToken && originalRequest) { originalRequest.headers.Authorization = `Bearer ${newToken}`; return apiClient.request(originalRequest); } } catch (refreshError) { console.error("[Auth] 토큰 갱신 실패:", refreshError); } } // 토큰 갱신 실패 또는 다른 401 에러인 경우 로그아웃 TokenManager.removeToken(); // 로그인 페이지가 아닌 경우에만 리다이렉트 if (window.location.pathname !== "/login") { console.log("[Auth] 로그인 페이지로 리다이렉트"); window.location.href = "/login"; } } 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) { console.error("현재 사용자 정보 조회 실패:", error); return { success: false, message: error.response?.data?.message || error.message || "사용자 정보를 가져올 수 없습니다.", errorCode: error.response?.data?.errorCode, }; } }; // API 호출 헬퍼 함수 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) { console.error("API 호출 실패:", error); 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, }; } };