Files
vexplor/frontend/lib/api/client.ts
DDD1542 5afa373b1f refactor: Update middleware and enhance component interactions
- Improved the middleware to handle authentication checks more effectively, ensuring that users are redirected appropriately based on their authentication status.
- Updated the InteractiveScreenViewerDynamic and RealtimePreviewDynamic components to utilize a new subscription method for DOM manipulation during drag events, enhancing performance and user experience.
- Refactored the SplitLineComponent to optimize drag handling and state management, ensuring smoother interactions during component adjustments.
- Integrated API client for menu data loading, streamlining token management and error handling.
2026-02-24 11:02:43 +09:00

497 lines
14 KiB
TypeScript

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);
document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`;
}
},
removeToken: (): void => {
if (typeof window !== "undefined") {
localStorage.removeItem("authToken");
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
}
},
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<string> => {
return new Promise((resolve, reject) => {
refreshSubscribers.push(resolve);
failedRefreshSubscribers.push(reject);
});
};
const refreshToken = async (): Promise<string | null> => {
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<typeof setInterval> | 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<typeof setTimeout> | 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<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) {
return {
success: false,
message: error.response?.data?.message || error.message || "사용자 정보를 가져올 수 없습니다.",
errorCode: error.response?.data?.errorCode,
};
}
};
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) {
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,
};
}
};