From a867daa98faa67f0b972b45ef0ce38430430cfe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Thu, 18 Jun 2026 13:12:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20TanStack=20Query=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20apis=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth/negotiation 도메인별 api/keys/queries/mutations/type 5파일 구조 - axios 인스턴스: access token 주입, 434 만료 시 refresh 자동 재발급(단일 비행) 후 원요청 재시도, 433/435/1203(TOKEN_REVOKED) 시 세션 종료 처리 - HTTP 200 + result.success=false 비즈니스 에러를 ApiError 로 변환, 에러코드별 한국어 메시지 맵(getApiErrorMessage) 제공 - 토큰 localStorage 저장소(tokenStorage) Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/apis/auth/auth.api.ts | 37 ++++++ frontend/src/apis/auth/auth.keys.ts | 5 + frontend/src/apis/auth/auth.mutations.ts | 55 ++++++++ frontend/src/apis/auth/auth.queries.ts | 20 +++ frontend/src/apis/auth/auth.type.ts | 91 ++++++++++++++ frontend/src/apis/auth/index.ts | 11 ++ frontend/src/apis/http.ts | 118 ++++++++++++++++++ frontend/src/apis/index.ts | 8 ++ frontend/src/apis/negotiation/index.ts | 6 + .../src/apis/negotiation/negotiation.api.ts | 34 +++++ .../src/apis/negotiation/negotiation.keys.ts | 8 ++ .../apis/negotiation/negotiation.mutations.ts | 32 +++++ .../apis/negotiation/negotiation.queries.ts | 17 +++ .../src/apis/negotiation/negotiation.type.ts | 80 ++++++++++++ frontend/src/apis/tokenStorage.ts | 27 ++++ frontend/src/apis/types.ts | 80 ++++++++++++ 16 files changed, 629 insertions(+) create mode 100644 frontend/src/apis/auth/auth.api.ts create mode 100644 frontend/src/apis/auth/auth.keys.ts create mode 100644 frontend/src/apis/auth/auth.mutations.ts create mode 100644 frontend/src/apis/auth/auth.queries.ts create mode 100644 frontend/src/apis/auth/auth.type.ts create mode 100644 frontend/src/apis/auth/index.ts create mode 100644 frontend/src/apis/http.ts create mode 100644 frontend/src/apis/index.ts create mode 100644 frontend/src/apis/negotiation/index.ts create mode 100644 frontend/src/apis/negotiation/negotiation.api.ts create mode 100644 frontend/src/apis/negotiation/negotiation.keys.ts create mode 100644 frontend/src/apis/negotiation/negotiation.mutations.ts create mode 100644 frontend/src/apis/negotiation/negotiation.queries.ts create mode 100644 frontend/src/apis/negotiation/negotiation.type.ts create mode 100644 frontend/src/apis/tokenStorage.ts create mode 100644 frontend/src/apis/types.ts diff --git a/frontend/src/apis/auth/auth.api.ts b/frontend/src/apis/auth/auth.api.ts new file mode 100644 index 0000000..780b0cc --- /dev/null +++ b/frontend/src/apis/auth/auth.api.ts @@ -0,0 +1,37 @@ +// 인증 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음). +// refresh_token 재발급은 http.ts 인터셉터가 자동 처리하므로 여기서 노출하지 않는다. +import { http } from '@/apis/http' +import type { + CreateAccountRequest, + CreateAccountResponse, + LoginRequest, + LoginResponse, + LogoutResponse, + MeResponse, +} from './auth.type' + +export const authApi = { + /** POST /v1/auth/login — ID/PW 로 로그인, access/refresh 토큰 발급 */ + login: async (body: LoginRequest): Promise => { + const res = await http.post('/v1/auth/login', body) + return res.data + }, + + /** POST /v1/auth/create — 신규 공급사 유저 계정 생성 */ + createAccount: async (body: CreateAccountRequest): Promise => { + const res = await http.post('/v1/auth/create', body) + return res.data + }, + + /** GET /v1/auth/me — 현재 로그인 유저 정보 (access token 필요) */ + me: async (): Promise => { + const res = await http.get('/v1/auth/me') + return res.data + }, + + /** POST /v1/auth/logout — 서버측 토큰 폐기(단일 세션) */ + logout: async (): Promise => { + const res = await http.post('/v1/auth/logout') + return res.data + }, +} diff --git a/frontend/src/apis/auth/auth.keys.ts b/frontend/src/apis/auth/auth.keys.ts new file mode 100644 index 0000000..3f19d6e --- /dev/null +++ b/frontend/src/apis/auth/auth.keys.ts @@ -0,0 +1,5 @@ +// 인증 도메인의 TanStack Query 키 팩토리. +export const authKeys = { + all: ['auth'] as const, + me: () => [...authKeys.all, 'me'] as const, +} diff --git a/frontend/src/apis/auth/auth.mutations.ts b/frontend/src/apis/auth/auth.mutations.ts new file mode 100644 index 0000000..1711229 --- /dev/null +++ b/frontend/src/apis/auth/auth.mutations.ts @@ -0,0 +1,55 @@ +// 인증 도메인의 변경(useMutation) 훅. +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { tokenStorage } from '@/apis/tokenStorage' +import { authApi } from './auth.api' +import { authKeys } from './auth.keys' +import type { CreateAccountRequest, LoginResponse } from './auth.type' + +/** 로그인 폼이 다루는 파라미터 (UI 친화적인 camelCase) */ +export interface LoginParams { + id: string + password: string +} + +/** + * 로그인: 성공 시 토큰을 저장하고 me 캐시를 무효화한다. + */ +export function useLoginMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ id, password }) => { + const data = await authApi.login({ id, pw: password }) + tokenStorage.setTokens(data.access_token, data.refresh_token) + return data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: authKeys.me() }) + }, + }) +} + +/** + * 로그아웃: 서버 토큰 폐기를 시도하고(실패해도) 로컬 토큰/캐시를 비운다. + */ +export function useLogoutMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async () => { + try { + await authApi.logout() + } finally { + tokenStorage.clear() + } + }, + onSettled: () => { + queryClient.clear() + }, + }) +} + +/** 공급사 유저 계정 생성 */ +export function useCreateAccountMutation() { + return useMutation({ + mutationFn: (body: CreateAccountRequest) => authApi.createAccount(body), + }) +} diff --git a/frontend/src/apis/auth/auth.queries.ts b/frontend/src/apis/auth/auth.queries.ts new file mode 100644 index 0000000..4167ecb --- /dev/null +++ b/frontend/src/apis/auth/auth.queries.ts @@ -0,0 +1,20 @@ +// 인증 도메인의 조회(useQuery) 훅. +import { useQuery } from '@tanstack/react-query' +import { tokenStorage } from '@/apis/tokenStorage' +import { authApi } from './auth.api' +import { authKeys } from './auth.keys' +import { toAuthUser } from './auth.type' + +/** + * 현재 로그인 유저 정보 조회. + * 토큰이 있을 때만 활성화되며, AuthUser(카멜케이스)로 가공해 반환한다. + */ +export function useMeQuery() { + return useQuery({ + queryKey: authKeys.me(), + queryFn: authApi.me, + enabled: tokenStorage.hasToken(), + staleTime: 5 * 60 * 1000, // 5분 + select: toAuthUser, + }) +} diff --git a/frontend/src/apis/auth/auth.type.ts b/frontend/src/apis/auth/auth.type.ts new file mode 100644 index 0000000..7b152ea --- /dev/null +++ b/frontend/src/apis/auth/auth.type.ts @@ -0,0 +1,91 @@ +// 인증 API 의 요청/응답 타입. +// 와이어 포맷은 백엔드(snake_case)를 그대로 미러링한다. +import type { ApiResult } from '@/apis/types' + +/** 유저 권한 (supplier_users.role) */ +export const UserRole = { + USER: 1, + MANAGER: 2, +} as const +export type UserRole = (typeof UserRole)[keyof typeof UserRole] + +export const USER_ROLE_LABEL: Record = { + [UserRole.USER]: '일반', + [UserRole.MANAGER]: '매니저', +} + +// --- 로그인 --------------------------------------------------------------- +export interface LoginRequest { + id: string + pw: string +} + +export interface LoginResponse { + result: ApiResult + su_id: string + name: string + supplier_id: string + supplier_name: string + role: number + access_token: string + refresh_token: string +} + +// --- 계정 생성 ------------------------------------------------------------ +export interface CreateAccountRequest { + supplier_id: string + id: string + pw: string + name?: string + email?: string + contact_number?: string + role?: number +} + +export interface CreateAccountResponse { + result: ApiResult + su_id: string +} + +// --- 토큰 재발급 ---------------------------------------------------------- +export interface RefreshTokenResponse { + result: ApiResult + access_token: string +} + +// --- 내 정보 (GET /v1/auth/me) ------------------------------------------- +export interface MeResponse { + result: ApiResult + su_id: string + id: string + name: string + supplier_id: string + supplier_name: string + role: number +} + +// --- 로그아웃 ------------------------------------------------------------- +export interface LogoutResponse { + result: ApiResult +} + +/** 앱에서 다루기 편한 현재 유저 형태 (MeResponse 에서 파생) */ +export interface AuthUser { + suId: string + loginId: string + name: string + supplierId: string + supplierName: string + role: number +} + +export function toAuthUser(res: MeResponse): AuthUser { + return { + suId: res.su_id, + loginId: res.id, + name: res.name, + supplierId: res.supplier_id, + supplierName: res.supplier_name, + role: res.role, + } +} diff --git a/frontend/src/apis/auth/index.ts b/frontend/src/apis/auth/index.ts new file mode 100644 index 0000000..9d8f09d --- /dev/null +++ b/frontend/src/apis/auth/index.ts @@ -0,0 +1,11 @@ +// 인증 API 모듈 공개 표면. +export { authApi } from './auth.api' +export { authKeys } from './auth.keys' +export { useMeQuery } from './auth.queries' +export { + useLoginMutation, + useLogoutMutation, + useCreateAccountMutation, + type LoginParams, +} from './auth.mutations' +export * from './auth.type' diff --git a/frontend/src/apis/http.ts b/frontend/src/apis/http.ts new file mode 100644 index 0000000..9f6d179 --- /dev/null +++ b/frontend/src/apis/http.ts @@ -0,0 +1,118 @@ +// 공용 axios 인스턴스. +// - 요청 시 access token 을 Authorization 헤더에 주입 +// - 응답의 result.success=false 봉투를 ApiError 로 변환 +// - access token 만료(434) 시 refresh_token 으로 1회 자동 재발급 후 원요청 재시도 +// - refresh 실패 / 토큰 폐기(1203) / refresh 만료(435) 시 세션 종료 처리 +import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios' +import { tokenStorage } from './tokenStorage' +import { ApiError, ErrorCode, type ApiResult } from './types' + +const BASE_URL = import.meta.env.VITE_API_BASE_URL + +export const http = axios.create({ + baseURL: BASE_URL, + headers: { 'Content-Type': 'application/json' }, +}) + +// 인증 만료 시 앱이 처리할 핸들러 (로그인 페이지로 이동 등). App 에서 등록한다. +let onUnauthorized: (() => void) | null = null +export function setUnauthorizedHandler(handler: (() => void) | null): void { + onUnauthorized = handler +} + +function handleUnauthorized(): void { + tokenStorage.clear() + onUnauthorized?.() +} + +// --- 요청 인터셉터: access token 주입 ------------------------------------ +http.interceptors.request.use((config) => { + const token = tokenStorage.getAccessToken() + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) + +// --- 토큰 재발급 (단일 비행: 동시 요청은 하나의 refresh 만 공유) ---------- +let refreshPromise: Promise | null = null + +async function refreshAccessToken(): Promise { + const refreshToken = tokenStorage.getRefreshToken() + if (!refreshToken) { + throw new ApiError(ErrorCode.HTTP_REFRESH_TOKEN_EXPIRED, 'NO_REFRESH_TOKEN') + } + // 인터셉터 재귀를 피하려고 인스턴스가 아닌 기본 axios 로 호출한다. + const res = await axios.post<{ result: ApiResult; access_token?: string }>( + `${BASE_URL}/v1/auth/refresh_token`, + null, + { headers: { Authorization: `Bearer ${refreshToken}` } }, + ) + const { result, access_token } = res.data + if (!result.success || !access_token) { + throw new ApiError(result.code, result.desc) + } + tokenStorage.setAccessToken(access_token) + return access_token +} + +function toApiError(error: unknown): ApiError { + if (error instanceof ApiError) return error + if (axios.isAxiosError(error)) { + const result = (error.response?.data as { result?: ApiResult } | undefined)?.result + if (result) return new ApiError(result.code, result.desc, error.message) + // 토큰 관련 HTTPException 은 result 봉투 대신 {detail: "HTTP_*"} 형태로 온다 + const detail = (error.response?.data as { detail?: string } | undefined)?.detail + const status = error.response?.status ?? 0 + return new ApiError(status, detail ?? error.code ?? 'NETWORK_ERROR', error.message) + } + return new ApiError(ErrorCode.FAIL, 'UNKNOWN', String(error)) +} + +// --- 응답 인터셉터 ------------------------------------------------------- +http.interceptors.response.use( + (response) => { + // HTTP 200 이지만 result.success=false 인 비즈니스 에러를 ApiError 로 변환 + const result = (response.data as { result?: ApiResult } | undefined)?.result + if (result && !result.success) { + // 저장 토큰 무효화(로그아웃/타기기 로그인)는 200 + TOKEN_REVOKED 로 온다 → 세션 종료 + if (result.code === ErrorCode.TOKEN_REVOKED && tokenStorage.hasToken()) { + handleUnauthorized() + } + throw new ApiError(result.code, result.desc) + } + return response + }, + async (error: AxiosError) => { + const status = error.response?.status + const original = error.config as + | (InternalAxiosRequestConfig & { _retried?: boolean }) + | undefined + const bodyCode = (error.response?.data as { result?: ApiResult } | undefined)?.result?.code + const isRefreshCall = original?.url?.includes('/v1/auth/refresh_token') ?? false + + // access token 만료 → refresh 후 1회 재시도 + if (status === 434 && original && !original._retried && !isRefreshCall) { + original._retried = true + try { + refreshPromise ??= refreshAccessToken().finally(() => { + refreshPromise = null + }) + const newToken = await refreshPromise + original.headers.Authorization = `Bearer ${newToken}` + return http(original) + } catch (refreshError) { + handleUnauthorized() + throw toApiError(refreshError) + } + } + + // 인증 실패(헤더 누락 403/401, 잘못된 토큰 433/436, refresh 만료 435) / + // 토큰 폐기(200+1203 이 아닌 경로) → 세션 종료 + const isAuthFailStatus = + status === 401 || status === 403 || status === 433 || status === 435 || status === 436 + if (isAuthFailStatus || bodyCode === ErrorCode.TOKEN_REVOKED || isRefreshCall) { + handleUnauthorized() + } + + throw toApiError(error) + }, +) diff --git a/frontend/src/apis/index.ts b/frontend/src/apis/index.ts new file mode 100644 index 0000000..e707984 --- /dev/null +++ b/frontend/src/apis/index.ts @@ -0,0 +1,8 @@ +// apis 레이어 공개 표면. +export { http, setUnauthorizedHandler } from './http' +export { tokenStorage } from './tokenStorage' +export { ApiError, ErrorCode, isApiError, getApiErrorMessage } from './types' +export type { ApiResult, ApiEnvelope } from './types' + +export * from './auth' +export * from './negotiation' diff --git a/frontend/src/apis/negotiation/index.ts b/frontend/src/apis/negotiation/index.ts new file mode 100644 index 0000000..f82d7e4 --- /dev/null +++ b/frontend/src/apis/negotiation/index.ts @@ -0,0 +1,6 @@ +// 협상 API 모듈 공개 표면. +export { negotiationApi } from './negotiation.api' +export { negotiationKeys } from './negotiation.keys' +export { useSessionListQuery } from './negotiation.queries' +export { useParticipateMutation, useRejectMutation } from './negotiation.mutations' +export * from './negotiation.type' diff --git a/frontend/src/apis/negotiation/negotiation.api.ts b/frontend/src/apis/negotiation/negotiation.api.ts new file mode 100644 index 0000000..0f78a67 --- /dev/null +++ b/frontend/src/apis/negotiation/negotiation.api.ts @@ -0,0 +1,34 @@ +// 협상 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음). +import { http } from '@/apis/http' +import type { + ParticipateResponse, + RejectRequest, + RejectResponse, + SessionListParams, + SessionListResponse, +} from './negotiation.type' + +export const negotiationApi = { + /** GET /v1/negotiation/sessions — 로그인 공급사의 협상 세션 목록(필터/정렬/페이지) */ + getSessions: async (params: SessionListParams = {}): Promise => { + const res = await http.get('/v1/negotiation/sessions', { params }) + return res.data + }, + + /** POST /v1/negotiation/sessions/{id}/participate — 협상 세션 참여 */ + participate: async (sessionId: string): Promise => { + const res = await http.post( + `/v1/negotiation/sessions/${sessionId}/participate`, + ) + return res.data + }, + + /** POST /v1/negotiation/sessions/{id}/reject — 협상 세션 거부 */ + reject: async (sessionId: string, body: RejectRequest): Promise => { + const res = await http.post( + `/v1/negotiation/sessions/${sessionId}/reject`, + body, + ) + return res.data + }, +} diff --git a/frontend/src/apis/negotiation/negotiation.keys.ts b/frontend/src/apis/negotiation/negotiation.keys.ts new file mode 100644 index 0000000..6461f70 --- /dev/null +++ b/frontend/src/apis/negotiation/negotiation.keys.ts @@ -0,0 +1,8 @@ +// 협상 도메인의 TanStack Query 키 팩토리. +import type { SessionListParams } from './negotiation.type' + +export const negotiationKeys = { + all: ['negotiation'] as const, + sessions: () => [...negotiationKeys.all, 'sessions'] as const, + sessionList: (params: SessionListParams) => [...negotiationKeys.sessions(), params] as const, +} diff --git a/frontend/src/apis/negotiation/negotiation.mutations.ts b/frontend/src/apis/negotiation/negotiation.mutations.ts new file mode 100644 index 0000000..947565f --- /dev/null +++ b/frontend/src/apis/negotiation/negotiation.mutations.ts @@ -0,0 +1,32 @@ +// 협상 도메인의 변경(useMutation) 훅. +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { negotiationApi } from './negotiation.api' +import { negotiationKeys } from './negotiation.keys' +import type { RejectRequest } from './negotiation.type' + +/** + * 협상 세션 참여: 성공 시 세션 목록 캐시를 무효화해 상태를 갱신한다. + */ +export function useParticipateMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (sessionId: string) => negotiationApi.participate(sessionId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() }) + }, + }) +} + +/** + * 협상 세션 거부: 성공 시 세션 목록 캐시를 무효화한다. + */ +export function useRejectMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ sessionId, request }: { sessionId: string; request: RejectRequest }) => + negotiationApi.reject(sessionId, request), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() }) + }, + }) +} diff --git a/frontend/src/apis/negotiation/negotiation.queries.ts b/frontend/src/apis/negotiation/negotiation.queries.ts new file mode 100644 index 0000000..38942cd --- /dev/null +++ b/frontend/src/apis/negotiation/negotiation.queries.ts @@ -0,0 +1,17 @@ +// 협상 도메인의 조회(useQuery) 훅. +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { negotiationApi } from './negotiation.api' +import { negotiationKeys } from './negotiation.keys' +import type { SessionListParams } from './negotiation.type' + +/** + * 협상 세션 목록 조회. + * 페이지 전환 시 이전 데이터를 유지해 깜빡임을 줄인다. + */ +export function useSessionListQuery(params: SessionListParams = {}) { + return useQuery({ + queryKey: negotiationKeys.sessionList(params), + queryFn: () => negotiationApi.getSessions(params), + placeholderData: keepPreviousData, + }) +} diff --git a/frontend/src/apis/negotiation/negotiation.type.ts b/frontend/src/apis/negotiation/negotiation.type.ts new file mode 100644 index 0000000..70ba859 --- /dev/null +++ b/frontend/src/apis/negotiation/negotiation.type.ts @@ -0,0 +1,80 @@ +// 협상 API 의 요청/응답 타입 + 코드값 enum. +// 와이어 포맷은 백엔드(snake_case)를 그대로 미러링한다. +import type { ApiResult } from '@/apis/types' + +/** 협상 세션 상태 (negotiation.sessions.status) */ +export const SessionStatus = { + CREATED: 1, // 협상생성(참여대기) + IN_PROGRESS: 2, // 협상중 + DONE: 3, // 협상완료 + NOT_PARTICIPATED: 4, // 미참여(마감) + REJECTED: 5, // 협상거부 +} as const +export type SessionStatus = (typeof SessionStatus)[keyof typeof SessionStatus] + +export const SESSION_STATUS_LABEL: Record = { + [SessionStatus.CREATED]: '협상생성', + [SessionStatus.IN_PROGRESS]: '협상중', + [SessionStatus.DONE]: '협상완료', + [SessionStatus.NOT_PARTICIPATED]: '미참여', + [SessionStatus.REJECTED]: '협상거부', +} + +/** 견적 타입 (negotiation.sessions.qt_type) */ +export const QtType = { + RENEGO: 1, // 재협상(1:1) + REQUOTE: 2, // 재견적(1:N) +} as const +export type QtType = (typeof QtType)[keyof typeof QtType] + +export const QT_TYPE_LABEL: Record = { + [QtType.RENEGO]: '재협상', + [QtType.REQUOTE]: '재견적', +} + +// --- 세션 목록 (GET /v1/negotiation/sessions) ---------------------------- +export interface SessionListParams { + status?: number // SessionStatus 코드 필터 + qt_type?: number // QtType 코드 필터 + order?: 'asc' | 'desc' // 마감(qt_end_time) 정렬, asc=임박순 + page?: number + page_size?: number +} + +export interface SessionListItem { + session_id: string + session_status: number + qt_type: number + qt_number: string + qt_end_time: string // ISO 8601 마감 시각 + item_code: string + item_name: string + model_name: string + maker_name: string +} + +export interface SessionListResponse { + result: ApiResult + items: SessionListItem[] + total: number + page: number + page_size: number +} + +// --- 참여 (POST /v1/negotiation/sessions/{id}/participate) ---------------- +export interface ParticipateResponse { + result: ApiResult + session_id: string +} + +// --- 거부 (POST /v1/negotiation/sessions/{id}/reject) --------------------- +// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트. +// (백엔드 sessions.reject_reason 컬럼에 대응. 엔드포인트는 백엔드 추가 예정) +export interface RejectRequest { + reject_reason: string +} + +export interface RejectResponse { + result: ApiResult + session_id: string +} diff --git a/frontend/src/apis/tokenStorage.ts b/frontend/src/apis/tokenStorage.ts new file mode 100644 index 0000000..ebc5c3a --- /dev/null +++ b/frontend/src/apis/tokenStorage.ts @@ -0,0 +1,27 @@ +// JWT access/refresh 토큰의 영속 저장소. +// axios 인터셉터(http.ts)와 인증 mutation 이 공유한다. + +const ACCESS_KEY = 'negosium.accessToken' +const REFRESH_KEY = 'negosium.refreshToken' + +export const tokenStorage = { + getAccessToken: (): string | null => localStorage.getItem(ACCESS_KEY), + getRefreshToken: (): string | null => localStorage.getItem(REFRESH_KEY), + + setTokens: (accessToken: string, refreshToken: string): void => { + localStorage.setItem(ACCESS_KEY, accessToken) + localStorage.setItem(REFRESH_KEY, refreshToken) + }, + + /** refresh_token 으로 access_token 만 갱신할 때 사용 */ + setAccessToken: (accessToken: string): void => { + localStorage.setItem(ACCESS_KEY, accessToken) + }, + + clear: (): void => { + localStorage.removeItem(ACCESS_KEY) + localStorage.removeItem(REFRESH_KEY) + }, + + hasToken: (): boolean => localStorage.getItem(ACCESS_KEY) !== null, +} diff --git a/frontend/src/apis/types.ts b/frontend/src/apis/types.ts new file mode 100644 index 0000000..0115bf8 --- /dev/null +++ b/frontend/src/apis/types.ts @@ -0,0 +1,80 @@ +// 모든 API 응답이 공유하는 공통 봉투(envelope)와 에러 타입. +// 백엔드는 HTTP 200 으로 내려주면서 result.success=false 로 비즈니스 에러를 표현한다. + +/** 백엔드가 모든 응답에 공통으로 내려주는 처리 결과 */ +export interface ApiResult { + success: boolean + code: number + desc: string +} + +/** result 봉투를 포함하는 응답의 베이스 */ +export interface ApiEnvelope { + result: ApiResult +} + +/** 백엔드 ErrorType 코드 (backend/common 의 ErrorType 과 1:1 매핑) */ +export const ErrorCode = { + SUCCESS: 0, + FAIL: 1, + DB_RUN_FAILED: 10, + DB_ALREADY_SAME_KEY: 11, + JSON_PARSE_ERROR: 100, + INVALID_REQUEST_DATA: 101, + INTERNAL_EXCEPTION: 102, + HTTP_INVALID_CLIENT_REQUEST: 419, + HTTP_TO_MANY_REQUEST: 429, + HTTP_INVALID_CLIENT_ACCESS: 433, + HTTP_ACCESS_TOKEN_EXPIRED: 434, + HTTP_REFRESH_TOKEN_EXPIRED: 435, + HTTP_INVALID_TOKEN_ACCESS: 436, + ACCOUNT_INVALID_INFO: 1200, + ACCOUNT_ALREADY_EXIST: 1201, + ACCOUNT_BLOCKED_USER: 1202, + TOKEN_REVOKED: 1203, + NEGO_FORBIDDEN: 1300, + NEGO_NOT_PARTICIPABLE: 1301, + NEGO_QUOTATION_CLOSED: 1302, + NEGO_DEADLINE_PASSED: 1303, + NEGO_NOT_FOUND: 1304, +} as const + +export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode] + +/** code → 사용자에게 보여줄 한국어 메시지 */ +const API_ERROR_MESSAGES: Record = { + [ErrorCode.ACCOUNT_INVALID_INFO]: '아이디 또는 비밀번호가 올바르지 않습니다.', + [ErrorCode.ACCOUNT_ALREADY_EXIST]: '이미 존재하는 아이디입니다.', + [ErrorCode.ACCOUNT_BLOCKED_USER]: '비활성화된 계정입니다. 관리자에게 문의하세요.', + [ErrorCode.TOKEN_REVOKED]: '다른 기기에서 로그인되어 세션이 종료되었습니다.', + [ErrorCode.HTTP_ACCESS_TOKEN_EXPIRED]: '로그인이 만료되었습니다. 다시 로그인해주세요.', + [ErrorCode.HTTP_REFRESH_TOKEN_EXPIRED]: '로그인이 만료되었습니다. 다시 로그인해주세요.', + [ErrorCode.NEGO_FORBIDDEN]: '해당 협상에 접근할 권한이 없습니다.', + [ErrorCode.NEGO_NOT_PARTICIPABLE]: '참여할 수 없는 협상입니다.', + [ErrorCode.NEGO_QUOTATION_CLOSED]: '마감된 견적입니다.', + [ErrorCode.NEGO_DEADLINE_PASSED]: '협상 마감 시간이 지났습니다.', + [ErrorCode.NEGO_NOT_FOUND]: '협상을 찾을 수 없습니다.', +} + +/** API 에러: result.code(비즈니스) 또는 HTTP status 를 code 로 담는다 */ +export class ApiError extends Error { + readonly code: number + readonly desc: string + + constructor(code: number, desc: string, message?: string) { + super(message ?? API_ERROR_MESSAGES[code] ?? desc) + this.name = 'ApiError' + this.code = code + this.desc = desc + } +} + +export function isApiError(error: unknown): error is ApiError { + return error instanceof ApiError +} + +/** code 에 해당하는 사용자 안내 메시지 (없으면 기본 문구) */ +export function getApiErrorMessage(error: unknown, fallback = '요청 처리 중 오류가 발생했습니다.'): string { + if (isApiError(error)) return API_ERROR_MESSAGES[error.code] ?? error.message ?? fallback + return fallback +}