import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios"; import { AuthLogger } from "@/lib/authLogger"; import { TokenManager } from "@/lib/auth/tokenManager"; const authLog = (event: string, detail: string) => { if (typeof window === "undefined") return; try { AuthLogger.log(event as any, detail); } catch { // 로거 실패해도 앱 동작에 영향 없음 } }; // API URL 동적 설정 // 우선순위: 1) 테넌트 서브도메인 → 직접 백엔드 2) 프로덕션 도메인 3) NEXT_PUBLIC_API_URL 4) default const getApiBaseUrl = (): string => { if (typeof window !== "undefined") { const currentHost = window.location.hostname; // 1. 테넌트 서브도메인 (*.invyone.com) 은 NEXT_PUBLIC_API_URL rewrite 우회 필수. // rewrite 가 Host 헤더를 변조하면 SubdomainResolverFilter 파싱 실패. // 운영 Traefik 이 같은 호스트의 /api 를 backend 로 프록시 → 동일 호스트 상대 주소. if (currentHost.endsWith(".invyone.com")) { return `https://${currentHost}/api`; } // 2. 프로덕션 메인 도메인 fallback (1번에서 endsWith 로 이미 처리되므로 dead-code 가깝지만, // invyone.com 루트 도메인 등 예외 케이스 보호용으로 유지) if (currentHost === "v1.invyone.com" || currentHost === "solution.invyone.com") { return "https://api.invyone.com/api"; } } // 3. 환경변수 (docker-compose 에서 /api 로 주입되면 Next rewrite 사용) if (process.env.NEXT_PUBLIC_API_URL) { return process.env.NEXT_PUBLIC_API_URL; } // 4. 로컬 기본 return "http://localhost:8081/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.endsWith(".invyone.com")) { return `https://api.invyone.com${imagePath}`; } if (currentHost === "localhost" || currentHost === "127.0.0.1") { return `http://localhost:8081${imagePath}`; } } const baseUrl = API_BASE_URL.replace(/\/api$/, ""); if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { return `${baseUrl}${imagePath}`; } return imagePath; } return imagePath; }; // ============================================ // 토큰 갱신 로직 (중복 요청 방지) // ============================================ 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) { authLog("TOKEN_REFRESH_FAIL", "갱신 시도했으나 토큰 자체가 없음"); return null; } authLog("TOKEN_REFRESH_START", `남은시간: ${Math.round(TokenManager.getTimeUntilExpiry(currentToken) / 60000)}분`); 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); authLog("TOKEN_REFRESH_SUCCESS", "토큰 갱신 완료"); return newToken; } authLog("TOKEN_REFRESH_FAIL", `API 응답 실패: success=${response.data?.success}`); return null; } catch (err: any) { authLog("TOKEN_REFRESH_FAIL", `API 호출 에러: ${err?.response?.status || err?.message || "unknown"}`); 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) { // idle 페이지에서 토큰이 사라진 걸 interval 이 감지. // API 호출이 없으면 401 인터셉터가 발동하지 않으므로 여기서 직접 리다이렉트. // redirectToLogin 내부에서 /login 경로는 스킵. authLog("AUTO_REFRESH_CHECK", "interval 중 토큰 없음 감지 → 로그인 리다이렉트"); redirectToLogin(); return; } if (TokenManager.isTokenExpired(token)) { // 이미 만료 — 한 번 갱신 시도하고 실패하면 로그인으로 const newToken = await refreshToken(); if (!newToken) { authLog("REDIRECT_TO_LOGIN", "interval 중 만료 토큰 갱신 실패 → 로그인"); redirectToLogin(); } return; } if (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) { // 탭 복귀 시 토큰이 아예 없다 = 다른 탭 로그아웃 / 저장소 정리 / WebView 리셋 등. // API 호출이 일어나지 않는 idle 페이지라면 401 인터셉터가 안 발동하므로 // 여기서 능동적으로 로그인 페이지로 보낸다. (redirectToLogin 내부에서 // /login 경로면 no-op 이라 안전.) authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 없음 → 로그인 리다이렉트"); redirectToLogin(); return; } if (TokenManager.isTokenExpired(token)) { authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 감지 → 갱신 시도"); refreshToken().then((newToken) => { if (!newToken) { authLog("REDIRECT_TO_LOGIN", "탭 복귀 후 토큰 갱신 실패로 리다이렉트"); redirectToLogin(); } }); } else if (TokenManager.isTokenExpiringSoon(token)) { authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 임박 → 갱신 시도"); refreshToken(); } } }); }; // 다른 탭에서 authToken 을 지우면 storage 이벤트가 발화한다 (같은 탭에서는 발화 X). // 멀티탭 환경에서 한 탭의 로그아웃이 즉시 다른 탭으로 전파되도록 한다. const setupStorageListener = (): void => { if (typeof window === "undefined") return; window.addEventListener("storage", (e) => { if (e.key !== "authToken") return; // newValue 가 null/빈 문자열이면 제거된 것. 새 값이 들어온 경우(다른 탭 로그인/갱신)는 그대로 따라감. if (!e.newValue) { authLog("STORAGE_SYNC", "다른 탭에서 authToken 제거 감지 → 로그인 리다이렉트"); redirectToLogin(); } }); }; // 사용자 활동 감지 기반 갱신 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; authLog("REDIRECT_TO_LOGIN", `리다이렉트 실행 (from: ${window.location.pathname})`); isRedirecting = true; TokenManager.removeToken(); window.location.href = "/login"; }; // 클라이언트 사이드에서 자동 갱신 시작 if (typeof window !== "undefined") { startAutoRefresh(); setupVisibilityRefresh(); setupActivityBasedRefresh(); setupStorageListener(); } // ============================================ // 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 { authLog("TOKEN_EXPIRED_DETECTED", `요청 전 토큰 만료 감지 (${config.url}) → 갱신 시도`); const newToken = await refreshToken(); if (newToken) { config.headers.Authorization = `Bearer ${newToken}`; } else { // 갱신 실패 = 세션 복구 불가. 로그인 페이지로 즉시 이동해야 호출부가 // 무한 로딩에 빠지지 않음. redirectToLogin 은 isRedirecting 플래그로 // 중복 호출을 막으므로 동시 요청이 여러 건이어도 안전함. authLog("TOKEN_REFRESH_FAIL", `요청 인터셉터에서 갱신 실패 → 로그인 리다이렉트 (${config.url})`); redirectToLogin(); return Promise.reject(new Error("TOKEN_REFRESH_FAILED")); } } } // 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); } // 403 — AuthGuard 외 경로에서도 반드시 막히도록 전역 리다이렉트 (backend filter 와 쌍) if (status === 403 && typeof window !== "undefined") { const errorData = error.response?.data as { errorCode?: string }; const ec = errorData?.errorCode; if (ec === "PASSWORD_CHANGE_REQUIRED" && window.location.pathname !== "/change-password") { authLog("REDIRECT_TO_CHANGE_PW", `403 PASSWORD_CHANGE_REQUIRED (${url})`); window.location.href = "/change-password"; return Promise.reject(error); } if (ec === "CROSS_TENANT_REJECTED") { authLog("REDIRECT_TO_LOGIN", `403 CROSS_TENANT_REJECTED (${url})`); TokenManager.removeToken(); if (window.location.pathname !== "/login") { window.location.href = "/login"; } return Promise.reject(error); } if (ec === "TENANT_NOT_RESOLVED") { authLog("REDIRECT_TO_LOGIN", `403 TENANT_NOT_RESOLVED (${url})`); TokenManager.removeToken(); if (window.location.pathname !== "/login") { window.location.href = "/login"; } return Promise.reject(error); } } // 401 에러 처리 (핵심 개선) if (status === 401 && typeof window !== "undefined") { const errorData = error.response?.data as { error?: { code?: string; details?: string } }; const errorCode = errorData?.error?.code; const errorDetails = errorData?.error?.details; const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; authLog("API_401_RECEIVED", `URL: ${url} | 코드: ${errorCode || "없음"} | 상세: ${errorDetails || "없음"}`); // 이미 재시도한 요청이면 로그인으로 if (originalRequest?._retry) { authLog("REDIRECT_TO_LOGIN", `재시도 후에도 401 (${url}) → 로그인 리다이렉트`); redirectToLogin(); return Promise.reject(error); } // 토큰 만료 에러 → 갱신 후 재시도 if (errorCode === "TOKEN_EXPIRED" && originalRequest) { if (!isRefreshing) { isRefreshing = true; originalRequest._retry = true; try { authLog("API_401_RETRY", `토큰 만료로 갱신 후 재시도 (${url})`); 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("토큰 갱신 실패")); authLog("REDIRECT_TO_LOGIN", `토큰 갱신 실패 (${url}) → 로그인 리다이렉트`); redirectToLogin(); return Promise.reject(error); } } catch (refreshError) { isRefreshing = false; onRefreshFailed(refreshError as Error); authLog("REDIRECT_TO_LOGIN", `토큰 갱신 예외 (${url}) → 로그인 리다이렉트`); 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 등 → 로그인으로 authLog("REDIRECT_TO_LOGIN", `복구 불가능한 인증 에러 (${errorCode || "UNKNOWN"}, ${url}) → 로그인 리다이렉트`); redirectToLogin(); } return Promise.reject(error); }, ); // ============================================ // 공통 타입 및 헬퍼 // ============================================ export interface ApiResponse { success: boolean; data?: T; message?: string; errorCode?: string; } export interface UserInfo { user_id: string; user_name: string; dept_name?: string; company_code?: string; user_type?: string; user_type_name?: string; email?: string; photo?: string; locale?: string; is_admin?: 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, }; } };