ERP-node/frontend/lib/api/client.ts

435 lines
13 KiB
TypeScript

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로 변환하는 함수
export const getFullImageUrl = (imagePath: string): string => {
// 이미 전체 URL인 경우 그대로 반환
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
return imagePath;
}
// /uploads로 시작하는 상대 경로인 경우 API 서버 주소 추가
if (imagePath.startsWith("/uploads")) {
const baseUrl = API_BASE_URL.replace("/api", ""); // /api 제거
return `${baseUrl}${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<string | null> | null = null;
// 토큰 갱신 함수
const refreshToken = async (): Promise<string | null> => {
// 이미 갱신 중이면 기존 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);
}
// 다른 에러들은 기존처럼 상세 로그 출력
console.error("API 응답 오류:", {
status: status,
statusText: error.response?.statusText,
url: url,
data: error.response?.data,
message: error.message,
headers: error.config?.headers,
});
// 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<T = unknown> {
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<ApiResponse<UserInfo>> => {
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 <T>(
method: "GET" | "POST" | "PUT" | "DELETE",
url: string,
data?: unknown,
): Promise<ApiResponse<T>> => {
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,
};
}
};