2단계 인증은 7.0.6 에서 서버측이 갖춰졌지만 인증번호를 입력할 화면이 어느 버전에도
없었다. 그래서 그 설정을 켠 사이트는 관리자를 포함한 전원이 로그인할 수 없었다.
원인은 `POST /api/auth/login` 이 조건에 따라 **다른 형태의 200** 을 돌려준다는 것이다.
평소에는 `{token, user}` 지만 2단계 인증이 켜져 있으면 `{two_factor_required,
challenge_id, ...}` 를 돌려준다. 프론트는 앞의 형태만 선언하고 `response.data.user.language`
를 바로 읽었으므로 그 자리에서 TypeError 가 났고, 영문 원문이 로그인 화면에 그대로 노출됐다.
서버는 정상 응답했으므로 서버 로그에는 아무 흔적도 남지 않는다.
이어서 `setToken(undefined)` 가 `localStorage` 에 문자열 `"undefined"` 를 남겼다.
이 값은 truthy 라 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 되고, 사용자에게는
「세션이 만료되었습니다」로 보인다. 관리자 로그인은 한발 더 나가 `null->isAdmin` 으로
500 이 되어, 설정을 되돌릴 수단까지 함께 사라졌다.
## 구현
- 로그인 응답을 판별 유니온(`LoginResult`)으로 표현하고, 형태를 판별한 뒤에 읽는다.
`ApiClient.setToken` 은 비어 있지 않은 문자열만 저장한다.
- 사용자·관리자 로그인 화면에 인증번호 입력 단계를 추가했다. 같은 카드 안에서 넘어가며
「인증번호 다시 받기」와 「처음부터」를 제공한다. 관리자 판정은 코드 확인에 성공한 뒤에
수행하고, 거부할 때는 그 직전에 발급된 토큰을 회수한다.
- 재발송(`login/two-factor/resend`)은 기존 challenge 를 취소하고 새로 발행한다. 유효한
코드를 여러 개 살려 두면 대입 시도의 표적이 넓어진다.
- 인증번호를 보내지 못하면 401 이 아니라 503 으로 답한다. 자격 증명은 올바른데 401 로
뭉개면 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진
사실을 알 방법이 없다.
- 공개 본인인증 경로(`identity/verify`·`cancel`)가 로그인 목적의 challenge 를 소진하지
못하도록 403 게이트를 세웠다. 소진되면 그 challenge 로 영영 로그인할 수 없다.
- 로그인 시도 제한 429 응답이 다국어 문구를 싣도록 했다(종전에는 프레임워크 기본 영문).
- 다국어 파라미터에서 파이프 표현식이 평가되지 않아 「유효시간 까지」처럼 값이 빠지던
문제를 함께 고쳤다. 같은 결함이 문의 목록 화면에도 있었다.
## 이번 점검에서 함께 고친 것
- 계정 잠금(423)·발송 실패(503) 응답이 사용자·관리자 컨트롤러에 동일하게 복제돼 있었고
그 주석 자신은 "단일 지점에서 만든다" 고 적혀 있었다. 페이로드에 필드가 하나 추가되면
한쪽만 따라가 같은 실패를 두 화면이 다르게 안내하게 된다 — 트레이트로 통합했다.
- 테스트가 개발자 자신의 사이트 설정을 읽고 있었다. 2단계 인증을 켜 둔 환경에서는 로그인
성공을 전제한 테스트가 503 으로 깨지는데 실패 메시지가 원인을 가리키지도 않는다.
같은 결함군을 위해 이미 존재하던 단일 지점에 그 축을 추가했다.
## 버전
코어 7.0.11 · sirsoft-basic 1.1.4 · sirsoft-admin_basic 1.0.9 ·
번들 일본어팩 3종 · 템플릿 엔진 engine-v1.65.0.
566 lines
19 KiB
TypeScript
566 lines
19 KiB
TypeScript
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<string, string> = {};
|
|
const rawHeaders = (originalRequest?.headers ?? {}) as Record<string, unknown>;
|
|
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<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
const response = await this.client.get<T>(url, config);
|
|
return response.data;
|
|
}
|
|
|
|
/**
|
|
* POST 요청
|
|
*/
|
|
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
|
const response = await this.client.post<T>(url, data, config);
|
|
return response.data;
|
|
}
|
|
|
|
/**
|
|
* PUT 요청
|
|
*/
|
|
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
|
const response = await this.client.put<T>(url, data, config);
|
|
return response.data;
|
|
}
|
|
|
|
/**
|
|
* PATCH 요청
|
|
*/
|
|
async patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
|
const response = await this.client.patch<T>(url, data, config);
|
|
return response.data;
|
|
}
|
|
|
|
/**
|
|
* DELETE 요청
|
|
*/
|
|
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
const response = await this.client.delete<T>(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<string, any>;
|
|
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;
|