- 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.
497 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
};
|