o2o-negosium-original/negodata/front/src/api/mutator/custom-fetch.ts
Mina Choi 7186446068 [feat] negodata/front: 협상대화 카드내용 렌더 + 견적상세 헤더·테이블 UI 정리
- 협상대화: 사용 카드 번호/멘트(Slate 서식본)/와일드 조건·메모 표시, SlateRenderer
  밑줄·헥스색·{변수} 치환 보정, 말풍선 우측정렬·발신자 아바타 제거
- 견적상세 헤더: DB 컬럼명 라벨 제거, 견적정보/(진행상태+세팅)/상품(이미지) 2열 재배치,
  협상카드 탭에 사용 카드 수 표시
- DataTable 행 패딩 축소, 협력사명 아바타 제거, 카드관리 본인 카드 안내문
- orval 재생성(QuotationCardData 신규 필드)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:58 +09:00

149 lines
5.6 KiB
TypeScript

// Orval mutator (기본 client 방식). axios 인터셉터를 대체하는 단일 길목 —
// 토큰 주입 / 에러 throw / 401 처리 / baseURL 을 여기서 처리한다.
//
// orval.config.ts 의 override.mutator 가 이 함수를 모든 생성 API 호출에 꽂는다.
// httpClient: 'fetch' 를 쓰지 않으므로 orval 은 config-object 시그니처로 호출한다:
// customFetch<T>({ url, method, params, data, signal, headers }, requestOptions)
// → 함수가 응답 본문(T)을 바로 반환하고, 상태코드별 ResponseXXX 유니온 타입은 생성되지 않는다.
// baseURL — compose 의 VITE_API_BASE_URL 로 주입, 없으면 9400 폴백.
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9400';
// 토큰 보관소 — 로그인 후 setAccessToken()으로 주입. localStorage(auth/service 와 동일 키)와 동기화.
const ACCESS_KEY = 'negodata.accessToken';
const REFRESH_KEY = 'negodata.refreshToken';
let accessToken: string | null = null;
export const setAccessToken = (token: string | null) => {
accessToken = token;
};
// 동시 다발 434(액세스 토큰 만료)를 단 한 번의 refresh 로 합치기 위한 in-flight 프라미스.
let refreshInFlight: Promise<string | null> | null = null;
// refresh 토큰으로 access 재발급. 성공 시 새 access 토큰, 실패 시 null.
async function refreshAccessToken(): Promise<string | null> {
if (refreshInFlight) return refreshInFlight;
refreshInFlight = (async () => {
try {
const refresh = localStorage.getItem(REFRESH_KEY);
if (!refresh) return null;
// refresh 토큰은 Authorization 헤더(Bearer)로 보낸다(auth/service restore() 와 동일 규약).
const res = await fetch(`${BASE_URL}/v1/auth/refresh_token`, {
method: 'POST',
headers: { Authorization: `Bearer ${refresh}` },
});
if (!res.ok) return null;
const refreshed = await res.json().catch(() => null);
const newToken: string | undefined = refreshed?.access_token;
if (!newToken || refreshed?.result?.success === false) return null;
localStorage.setItem(ACCESS_KEY, newToken);
accessToken = newToken;
return newToken;
} catch {
return null;
} finally {
refreshInFlight = null;
}
})();
return refreshInFlight;
}
function clearTokens() {
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
accessToken = null;
}
export class ApiError extends Error {
constructor(
public status: number,
public statusText: string,
public data: unknown,
) {
super(`API ${status} ${statusText}`);
this.name = 'ApiError';
}
}
export type RequestConfig = {
url: string;
method: string;
params?: Record<string, unknown>;
data?: unknown;
headers?: HeadersInit;
signal?: AbortSignal;
responseType?: string;
};
export const customFetch = async <T>(
config: RequestConfig,
options?: RequestInit,
): Promise<T> => {
const { url, method, params, data, signal } = config;
// 쿼리스트링 직렬화 (null/undefined 값은 제외)
const queryString = params
? new URLSearchParams(
Object.entries(params)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => [k, String(v)]),
).toString()
: '';
const requestUrl =
(url.startsWith('http') ? url : `${BASE_URL}${url}`) +
(queryString ? `?${queryString}` : '');
// 파일 업로드(FormData)는 ① 브라우저가 boundary 포함 Content-Type 을 직접 설정해야 하고
// ② 본문을 JSON.stringify 하면 "{}" 가 되어 파일이 통째로 누락된다. 분기 처리.
const isFormData = typeof FormData !== 'undefined' && data instanceof FormData;
// [요청 인터셉터] 토큰을 받아 헤더를 구성하고 한 번 호출(만료 재시도 시 새 토큰으로 재구성).
const doFetch = (token: string | null) => {
const headers = new Headers(config.headers);
if (options?.headers) {
new Headers(options.headers).forEach((v, k) => headers.set(k, v));
}
if (isFormData) {
headers.delete('Content-Type'); // 생성 클라이언트가 박은 boundary 없는 multipart 헤더 제거 → 브라우저가 재설정
} else if (data != null && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (token) headers.set('Authorization', `Bearer ${token}`);
return fetch(requestUrl, {
...options,
method,
headers,
body: data != null ? (isFormData ? data : JSON.stringify(data)) : options?.body,
signal,
});
};
let response = await doFetch(accessToken);
// 434(액세스 토큰 만료) → refresh 로 재발급 후 1회 재시도. auth 엔드포인트 자신은 제외(무한루프 방지).
const isAuthEndpoint = url.includes('/v1/auth/refresh_token') || url.includes('/v1/auth/login');
if ((response.status === 434 || response.status === 401) && !isAuthEndpoint) {
const newToken = await refreshAccessToken();
if (newToken) {
response = await doFetch(newToken);
} else {
clearTokens(); // refresh 실패 → 토큰 폐기(다음 로드/가드에서 로그인으로 유도)
}
}
// [응답 인터셉터] 본문 파싱 (204/빈 응답 대응)
const isJson = response.headers.get('content-type')?.includes('application/json');
const body =
response.status === 204 ? null : isJson ? await response.json() : await response.text();
// fetch는 4xx/5xx도 resolve하므로 직접 throw → React Query가 error로 처리
if (!response.ok) {
throw new ApiError(response.status, response.statusText, body);
}
return body as T;
};
export default customFetch;