import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; import { AuthManager } from '../auth/AuthManager'; import { createLogger } from '../utils/Logger'; import { IdentityGuardInterceptor } from '../identity/IdentityGuardInterceptor'; import type { G7DevToolsCore } from '../devtools/G7DevToolsCore'; const logger = createLogger('ApiClient'); /** * DevTools 인스턴스 가져오기 */ function getDevTools(): G7DevToolsCore | null { if (typeof window !== 'undefined' && (window as any).__G7_DEVTOOLS__) { return (window as any).__G7_DEVTOOLS__ as G7DevToolsCore; } return null; } /** * Axios 설정에 DevTools 요청 ID를 저장하기 위한 확장 인터페이스 */ interface AxiosConfigWithDevTools extends InternalAxiosRequestConfig { _devToolsRequestId?: string | null; _retry?: boolean; /** * IDV 428 인터셉트 시 challenge 에 사용할 인증 대상(이메일·전화). * axios(G7Core.api) 직접 호출 경로에서 호출자가 config 에 실어 보내면 인터셉터가 launcher 로 전달. * apiCall(handleApiCall) 경로의 `identity_target` 액션 속성과 동일한 역할. */ identity_target?: { email?: string; phone?: string }; } /** * API 에러 정보 */ export interface ApiErrorInfo { /** HTTP 상태 코드 */ status: number; /** 에러 메시지 */ message: string; /** API 응답 데이터 */ data?: any; /** HTTP 상태 텍스트 */ statusText?: string; } /** * API 에러 핸들러 타입 */ export type ApiErrorHandler = (error: ApiErrorInfo) => void; /** * API 클라이언트 설정 인터페이스 */ interface ApiClientConfig { baseURL?: string; timeout?: number; /** @deprecated onUnauthorized 대신 onError를 사용하세요 */ onTokenExpired?: () => void; /** 401 토큰 갱신 실패 시 호출 (로그인 페이지 리다이렉트용) */ onUnauthorized?: () => void; /** 글로벌 에러 핸들러 (모든 API 에러에 대해 호출) */ onError?: ApiErrorHandler; } /** * API 클라이언트 클래스 * 인증이 필요한 API 요청에 자동으로 Authorization 헤더를 추가합니다. */ class ApiClient { private client: AxiosInstance; private config: ApiClientConfig; private readonly TOKEN_KEY = 'auth_token'; constructor(config: ApiClientConfig = {}) { this.config = { baseURL: config.baseURL || '/api', timeout: config.timeout || 30000, onTokenExpired: config.onTokenExpired, // eslint-disable-line deprecation/deprecation onUnauthorized: config.onUnauthorized, onError: config.onError, }; this.client = axios.create({ baseURL: this.config.baseURL, timeout: this.config.timeout, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, // 배열 파라미터를 key[]=a&key[]=b 형식으로 직렬화 (Laravel 호환) paramsSerializer: { serialize: (params) => { const parts: string[] = []; for (const [key, value] of Object.entries(params)) { if (value === null || value === undefined) continue; if (Array.isArray(value)) { // 배열: key[]=a&key[]=b 형식 // 키에 이미 []가 있으면 그대로 사용, 없으면 []를 추가 const arrayKey = key.endsWith('[]') ? key : `${key}[]`; for (const item of value) { parts.push(`${encodeURIComponent(arrayKey)}=${encodeURIComponent(String(item))}`); } } else { // 일반 값 parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); } } return parts.join('&'); }, }, }); this.setupInterceptors(); } /** * 토큰 저장 * * 문자열이 아닌 값은 저장하지 않는다. `localStorage` 는 무엇을 넣든 문자열로 바꿔 * 저장하므로 `undefined` 를 넘기면 `"undefined"` 라는 truthy 문자열이 남고, 이후 * 모든 요청이 `Bearer undefined` 로 나가 401 로 튕긴다 — 화면에는 "세션이 만료되었습니다" * 로 보여 원인을 추적할 단서가 남지 않는다. * * @since engine-v1.65.0 */ setToken(token: string): void { if (typeof token !== 'string' || token === '') { logger.warn('Ignoring invalid auth token — token must be a non-empty string.'); return; } if (typeof window !== 'undefined') { localStorage.setItem(this.TOKEN_KEY, token); } } /** * 토큰 조회 */ getToken(): string | null { if (typeof window !== 'undefined') { return localStorage.getItem(this.TOKEN_KEY); } return null; } /** * 토큰 삭제 */ removeToken(): void { if (typeof window !== 'undefined') { localStorage.removeItem(this.TOKEN_KEY); } } /** * 인터셉터 설정 */ private setupInterceptors(): void { // 요청 인터셉터 this.client.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // URL이 이미 /api로 시작하면 baseURL 제거 (중복 방지) if (config.url?.startsWith('/api') && config.baseURL === '/api') { config.baseURL = ''; } // 인증 토큰은 동일 출처 요청에만 첨부한다. 공개 자산 디스크(S3/CDN)를 켜면 // 첨부 URL 이 외부 origin 절대 URL 이 되는데, 그 요청에 Authorization 을 실으면 // ① 세션 토큰이 제3자 origin 의 접근 로그에 남고 // ② 안전목록 밖 헤더라 preflight 가 발생해 CORS 미설정 CDN 에서는 이미지가 통째로 실패한다. const token = this.getToken(); if (token && config.headers && !this.isCrossOriginRequest(config.url)) { config.headers.Authorization = `Bearer ${token}`; } // g7_locale이 설정되어 있으면 Accept-Language 헤더로 전송 if (typeof window !== 'undefined' && config.headers) { const locale = localStorage.getItem('g7_locale'); if (locale) { config.headers['Accept-Language'] = locale; } } // DevTools 요청 추적 시작 const devTools = getDevTools(); if (devTools?.isEnabled()) { const fullUrl = this.buildFullUrl(config); const method = (config.method || 'GET').toUpperCase(); const requestId = devTools.trackRequest( fullUrl, method, { requestBody: config.data } ); (config as AxiosConfigWithDevTools)._devToolsRequestId = requestId; } return config; }, (error) => { return Promise.reject(error); } ); // 응답 인터셉터 this.client.interceptors.response.use( (response: AxiosResponse) => { // DevTools 요청 완료 추적 (성공) const config = response.config as AxiosConfigWithDevTools; const requestId = config._devToolsRequestId; if (requestId) { const devTools = getDevTools(); if (devTools?.isEnabled()) { devTools.completeRequest(requestId, response.status, response.data); } config._devToolsRequestId = null; } return response; }, async (error) => { const originalRequest = error.config as AxiosConfigWithDevTools | undefined; const requestUrl = originalRequest?.url || ''; // onUnauthorized 콜백 실행을 건너뛸 엔드포인트 패턴 // - /auth/: 인증 관련 API (로그인 실패 시 리다이렉트 방지, 토큰 갱신 무한 루프 방지) // - /layouts/: 레이아웃 서빙 API (공개 접근 가능, 토큰 만료 시 로그인 페이지 접근 허용) const skipUnauthorizedPatterns = [ '/auth/', '/layouts/', ]; const shouldSkipUnauthorized = skipUnauthorizedPatterns.some(pattern => requestUrl.includes(pattern)); // 401 Unauthorized 처리 if (error.response?.status === 401 && !originalRequest?._retry && !shouldSkipUnauthorized) { originalRequest!._retry = true; // AuthManager를 통한 토큰 갱신 시도 const authManager = AuthManager.getInstance(); const refreshed = await authManager.refreshToken(); if (refreshed) { // 토큰 갱신 성공, 원래 요청 재시도 (DevTools 추적은 재시도된 요청에서 처리) const token = this.getToken(); if (token) { originalRequest!.headers.Authorization = `Bearer ${token}`; } // 재시도 전 requestId 클리어 (새 요청으로 다시 추적됨) originalRequest!._devToolsRequestId = null; return this.client(originalRequest!); } // 갱신 실패 - DevTools에 에러 기록 this.completeDevToolsRequest(originalRequest, error); this.removeToken(); if (this.config.onUnauthorized) { this.config.onUnauthorized(); } return Promise.reject(error); } // 인증 관련 요청이 401인 경우 (로그인 실패, 토큰 만료/삭제 등) // 각 호출 측에서 에러를 직접 처리하도록 함 if (error.response?.status === 401 && shouldSkipUnauthorized) { this.completeDevToolsRequest(originalRequest, error); return Promise.reject(error); } // 428 본인인증 필요 처리 (IDV) — apiCall(handleApiCall, native fetch) 경로는 // ActionDispatcher 가 IdentityGuardInterceptor 로 자동 인터셉트하지만, G7Core.api(axios) // 직접 호출 경로(입금확인/주문취소 등 모듈 JS 핸들러)는 이 인터셉터를 거치지 않아 모듈마다 // 428 분기를 수동 추가해야 했고 누락 위험이 있었다(취소 핸들러 미노출 회귀). 여기서 중앙 // 처리하면 axios 를 쓰는 모든 호출이 본인인증 모달 → verify → 원 요청 재실행을 자동 수행한다. if (IdentityGuardInterceptor.isIdentityRequired(error.response?.status, error.response?.data)) { this.completeDevToolsRequest(originalRequest, error); // 원 요청 헤더(Authorization 포함)/body 를 재실행에 그대로 재사용 — 누락 시 백엔드가 // 인증 실패(401) 또는 빈 body(422)로 응답할 수 있다. const replayHeaders: Record = {}; const rawHeaders = (originalRequest?.headers ?? {}) as Record; for (const [k, v] of Object.entries(rawHeaders)) { if (typeof v === 'string') { replayHeaders[k] = v; } } let replayBody: BodyInit | undefined; if (originalRequest?.data !== undefined && originalRequest?.data !== null) { replayBody = typeof originalRequest.data === 'string' ? originalRequest.data : JSON.stringify(originalRequest.data); replayHeaders['Content-Type'] = replayHeaders['Content-Type'] ?? 'application/json'; } // 호출자가 config.identity_target 으로 선언한 인증 대상(비로그인 흐름) 을 launcher 로 전달. const identityTarget = (originalRequest as AxiosConfigWithDevTools | undefined) ?.identity_target; const replayed = await IdentityGuardInterceptor.handle( error.response.data, { headers: replayHeaders, body: replayBody, credentials: 'same-origin', }, identityTarget ); // 사용자 취소 / verify 실패 / return_request 없음 → 원 에러 그대로 전파 if (!replayed) { return Promise.reject(error); } // 재실행 성공 → fetch Response 를 axios 응답 형태로 변환해 정상 흐름으로 반환. let replayData: any = null; try { replayData = await replayed.json(); } catch { replayData = null; } if (!replayed.ok) { // 재실행이 또 실패하면 그 응답을 에러로 전파 return Promise.reject(Object.assign(new Error('replay failed'), { response: { status: replayed.status, data: replayData }, })); } return { ...error.response, status: replayed.status, data: replayData, } as AxiosResponse; } // 403 Forbidden 처리 - onUnauthorized를 호출하지 않고 onError로 처리 // 403은 "권한 없음"이므로 로그인 페이지로 리다이렉트하면 안됨 if (error.response?.status === 403) { this.completeDevToolsRequest(originalRequest, error); this.callOnError(error); return Promise.reject(error); } // 기타 에러 처리 (404, 422, 500 등) this.completeDevToolsRequest(originalRequest, error); if (error.response) { this.callOnError(error); } return Promise.reject(error); } ); } /** * GET 요청 */ async get(url: string, config?: AxiosRequestConfig): Promise { const response = await this.client.get(url, config); return response.data; } /** * POST 요청 */ async post(url: string, data?: any, config?: AxiosRequestConfig): Promise { const response = await this.client.post(url, data, config); return response.data; } /** * PUT 요청 */ async put(url: string, data?: any, config?: AxiosRequestConfig): Promise { const response = await this.client.put(url, data, config); return response.data; } /** * PATCH 요청 */ async patch(url: string, data?: any, config?: AxiosRequestConfig): Promise { const response = await this.client.patch(url, data, config); return response.data; } /** * DELETE 요청 */ async delete(url: string, config?: AxiosRequestConfig): Promise { const response = await this.client.delete(url, config); return response.data; } /** * Axios 인스턴스 직접 접근 (고급 사용) */ getInstance(): AxiosInstance { return this.client; } /** * onUnauthorized 콜백 설정 * * 토큰 갱신 실패 시 호출될 콜백을 설정합니다. * 싱글톤 인스턴스 생성 후에도 콜백을 설정할 수 있습니다. * * @param callback 401 토큰 갱신 실패 시 호출될 콜백 */ setOnUnauthorized(callback: () => void): void { this.config.onUnauthorized = callback; } /** * 글로벌 에러 핸들러 설정 * * API 에러 발생 시 호출될 핸들러를 설정합니다. * ErrorHandlingResolver와 연동하여 계층적 에러 핸들링을 수행합니다. * * @param handler 에러 핸들러 함수 */ setOnError(handler: ApiErrorHandler): void { this.config.onError = handler; } /** * DevTools 요청 완료 추적 (에러 응답용) * * @param config Axios 요청 설정 * @param error Axios 에러 객체 */ private completeDevToolsRequest(config: AxiosConfigWithDevTools | undefined, error: any): void { if (!config?._devToolsRequestId) return; const devTools = getDevTools(); if (!devTools?.isEnabled()) return; const requestId = config._devToolsRequestId; config._devToolsRequestId = null; if (error.response) { // HTTP 응답이 있는 경우 (4xx, 5xx 등) devTools.completeRequest(requestId, error.response.status, error.response.data); } else if (error.request) { // 요청은 보냈지만 응답이 없는 경우 (네트워크 오류, 타임아웃 등) devTools.failRequest(requestId, error.message || 'Network error'); } else { // 요청 설정 중 오류 devTools.failRequest(requestId, error.message || 'Request error'); } } /** * 요청 URL 이 현재 문서와 다른 출처인지 판정합니다. * * 상대 경로는 항상 동일 출처입니다. 절대 URL(`https://…`)과 프로토콜 상대 * URL(`//host/…`)만 출처를 비교하며, 파싱 불가하거나 브라우저 밖(SSR/테스트) * 이면 동일 출처로 간주해 기존 동작을 유지합니다. * * @param url 요청 URL (Axios config.url) * @return bool 교차 출처 여부 */ private isCrossOriginRequest(url?: string): boolean { if (!url || typeof window === 'undefined' || !window.location) { return false; } if (!/^(https?:)?\/\//i.test(url)) { return false; } try { return new URL(url, window.location.href).origin !== window.location.origin; } catch { return false; } } /** * 요청 설정에서 전체 URL을 생성합니다. * * @param config Axios 요청 설정 * @returns 전체 URL 문자열 */ private buildFullUrl(config: InternalAxiosRequestConfig): string { let url = config.url || ''; // baseURL이 있고, url이 상대 경로인 경우 결합 if (config.baseURL && !url.startsWith('http')) { url = `${config.baseURL}${url.startsWith('/') ? '' : '/'}${url}`; } // 쿼리 파라미터가 있는 경우 추가 if (config.params && typeof config.params === 'object') { const params = config.params as Record; const parts: string[] = []; for (const [key, value] of Object.entries(params)) { if (value === null || value === undefined) continue; if (Array.isArray(value)) { const arrayKey = key.endsWith('[]') ? key : `${key}[]`; for (const item of value) { parts.push(`${encodeURIComponent(arrayKey)}=${encodeURIComponent(String(item))}`); } } else { parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); } } if (parts.length > 0) { url += (url.includes('?') ? '&' : '?') + parts.join('&'); } } return url; } /** * 글로벌 에러 핸들러를 호출합니다. * * @param error Axios 에러 객체 */ private callOnError(error: any): void { if (!this.config.onError) { return; } const errorInfo: ApiErrorInfo = { status: error.response?.status || 0, message: error.response?.data?.message || error.message || 'Unknown error', data: error.response?.data, statusText: error.response?.statusText, }; try { this.config.onError(errorInfo); } catch (handlerError) { logger.error('Error in onError handler:', handlerError); } } } // 싱글톤 인스턴스 let apiClientInstance: ApiClient | null = null; /** * API 클라이언트 인스턴스 생성 또는 가져오기 */ export function createApiClient(config?: ApiClientConfig): ApiClient { if (!apiClientInstance) { apiClientInstance = new ApiClient(config); } return apiClientInstance; } /** * 기본 API 클라이언트 인스턴스 가져오기 */ export function getApiClient(): ApiClient { if (!apiClientInstance) { apiClientInstance = new ApiClient(); } return apiClientInstance; } export default ApiClient;