import 'server-only'; import { getApiBaseUrl, getDevApiAccessToken } from '@/lib/env'; /** * 백엔드(edupay-backend) 통신 규약의 단일 지점 (설계서 §4 `server/api-client`). * * 이 파일이 아는 것은 "백엔드와 말하는 법"뿐이다 — URL 조립, 인증 헤더 부착, 공통 응답 봉투 * 해석, 실패 정규화. 어떤 화면이 무엇을 조회하는지(엔드포인트·파라미터·필드 매핑)는 각 * Repository의 책임이라 여기로 들어오지 않는다. 반환 타입이 `unknown`인 것도 그 때문이다 — * 응답 shape 검증은 그 shape을 아는 Repository가 한다. * * 브라우저는 백엔드를 직접 호출하지 않는다(설계서 §7 BFF 전제). `server-only`로 클라이언트 * 번들 유입 시 빌드가 실패하게 막아 이 전제를 도구로 강제한다. */ /** 백엔드 공통 응답 봉투. 성공·실패 모두 이 형태로 온다. */ type ApiEnvelope = { success?: unknown; code?: unknown; message?: unknown; data?: unknown; }; export type ApiErrorKind = /** 401 — 토큰이 없거나 만료·무효다. */ | 'unauthorized' /** 백엔드가 입력값을 거절했다(예: code 900 입력값 무결성 오류). */ | 'validation' /** 백엔드가 그 외 실패를 반환했다. */ | 'server' /** 백엔드에 닿지 못했다(DNS·타임아웃·TLS 등). */ | 'network' /** 닿았지만 약속된 형태가 아니다(JSON 파싱 실패, 필드 누락 등). */ | 'contract'; /** * 백엔드 호출 실패의 정규화된 표현 (설계서 §8.1 6층 "실패 안전"). * 화면에는 이 메시지를 그대로 내보내지 않는다 — 호출부가 일반화된 문구로 바꿔 보여준다. */ export class ApiError extends Error { readonly kind: ApiErrorKind; /** 백엔드가 준 code. HTTP 레벨에서 끊긴 경우엔 HTTP status, 그마저 없으면 null. */ readonly code: number | null; constructor( kind: ApiErrorKind, message: string, code: number | null = null, options?: { cause?: unknown } ) { super(message, options); this.name = 'ApiError'; this.kind = kind; this.code = code; } } /** * 요청에 붙일 백엔드 accessToken을 얻는다 — **인증 연동의 유일한 이식 지점**이다. * * 현재 세션 토큰(`lib/auth/session-token.ts`)은 mock 관리자의 `adminId`만 담고 있어 부착할 * 백엔드 토큰이 없다. 관리자 로그인 연동(별도 작업)이 들어오면 이 함수 하나만 * "세션에서 accessToken을 읽어 반환"하도록 바꾸면 되고, 개별 Repository는 손대지 않는다. * 그때 개발용 env 폴백(`getDevApiAccessToken`)은 함께 제거한다. */ async function resolveAccessToken(): Promise { return getDevApiAccessToken(); } /** base URL과 경로를 합친다. base 끝의 `/`·경로 앞의 `/` 유무와 무관하게 같은 URL을 만든다. */ function buildRequestUrl( path: string, params: Record ): string { const base = getApiBaseUrl().replace(/\/+$/, ''); const normalizedPath = path.startsWith('/') ? path : `/${path}`; const url = new URL(`${base}${normalizedPath}`); for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, String(value)); } return url.toString(); } /** 실패 응답에서 code/message를 최대한 건져낸다 — 형태가 깨져 있어도 throw하지 않는다. */ function readEnvelopeCode(envelope: ApiEnvelope): number | null { return typeof envelope.code === 'number' ? envelope.code : null; } function readEnvelopeMessage(envelope: ApiEnvelope, fallback: string): string { return typeof envelope.message === 'string' && envelope.message ? envelope.message : fallback; } function classifyFailure(code: number | null): ApiErrorKind { if (code === 401 || code === 403) { return 'unauthorized'; } // 900 = 입력값 무결성 오류(백엔드 통지 계약). if (code === 900) { return 'validation'; } return 'server'; } async function readEnvelope(response: Response): Promise { try { const parsed: unknown = await response.json(); return parsed !== null && typeof parsed === 'object' ? (parsed as ApiEnvelope) : {}; } catch { return {}; } } /** * 백엔드 GET 조회. 성공 시 봉투의 `data`만 돌려주고, 실패는 전부 `ApiError`로 정규화한다. * * 캐시: `no-store` 고정. 어드민은 데이터 신선도가 우선이고(설계서 §7.1), 조회 대상이 * 개인정보이며 검색 조건이 매 요청 달라져 재사용 캐시를 둘 이유가 없다. * * 성공 판정에 `success` 플래그를 단독으로 믿지 않는다 — 실측 결과 401 응답이 * `{"success":true,"auth":false,"code":401,...}`로 와서 `success`만 보면 실패를 성공으로 * 읽는다. HTTP 상태와 `code`를 함께 확인한다. */ export async function apiGet( path: string, params: Record = {} ): Promise { const url = buildRequestUrl(path, params); const accessToken = await resolveAccessToken(); let response: Response; try { response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, cache: 'no-store', }); } catch (cause) { throw new ApiError( 'network', `백엔드에 연결하지 못했습니다: ${path}`, null, { cause } ); } const envelope = await readEnvelope(response); const code = readEnvelopeCode(envelope) ?? response.status; if (!response.ok) { throw new ApiError( classifyFailure(code), readEnvelopeMessage(envelope, `백엔드 응답 실패(HTTP ${response.status})`), code ); } if (envelope.success !== true || code !== 200) { throw new ApiError( classifyFailure(code), readEnvelopeMessage(envelope, '백엔드 응답 실패'), code ); } return envelope.data; }