- 협상대화: 사용 카드 번호/멘트(Slate 서식본)/와일드 조건·메모 표시, SlateRenderer
밑줄·헥스색·{변수} 치환 보정, 말풍선 우측정렬·발신자 아바타 제거
- 견적상세 헤더: DB 컬럼명 라벨 제거, 견적정보/(진행상태+세팅)/상품(이미지) 2열 재배치,
협상카드 탭에 사용 카드 수 표시
- DataTable 행 패딩 축소, 협력사명 아바타 제거, 카드관리 본인 카드 안내문
- orval 재생성(QuotationCardData 신규 필드)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
149 lines
5.6 KiB
TypeScript
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;
|