464 lines
14 KiB
TypeScript
464 lines
14 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로 변환하는 함수
|
|
// 주의: 모듈 로드 시점이 아닌 런타임에 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<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);
|
|
}
|
|
|
|
// 채번 규칙 미리보기 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<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,
|
|
};
|
|
}
|
|
};
|