import { CrawlingResponse, LyricGenerateRequest, LyricGenerateResponse, LyricStatusResponse, LyricDetailResponse, SongGenerateRequest, SongGenerateResponse, SongStatusResponse, SongDownloadResponse, VideoGenerateResponse, VideoStatusResponse, VideoDownloadResponse, VideosListResponse, ImageUrlItem, ImageUploadResponse, KakaoLoginUrlResponse, KakaoCallbackResponse, TokenRefreshResponse, UserMeResponse, } from '../types/api'; const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44'; console.log('[API] API_URL:', API_URL); console.log('[API] VITE_API_URL env:', import.meta.env.VITE_API_URL); // 크롤링 타임아웃: 5분 const CRAWL_TIMEOUT = 5 * 60 * 1000; export async function crawlUrl(url: string): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT); try { const response = await fetch(`${API_URL}/crawling`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ url }), signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === 'AbortError') { throw new Error('크롤링 요청 시간이 초과되었습니다. 다시 시도해주세요.'); } throw error; } } // 가사 생성 API export async function generateLyric(request: LyricGenerateRequest): Promise { const response = await authenticatedFetch(`${API_URL}/lyric/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(request), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 가사 상태 조회 API export async function getLyricStatus(taskId: string): Promise { const response = await authenticatedFetch(`${API_URL}/lyric/status/${taskId}`, { method: 'GET', }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 가사 상세 조회 API export async function getLyricDetail(taskId: string): Promise { const response = await authenticatedFetch(`${API_URL}/lyric/${taskId}`, { method: 'GET', }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 가사 생성 완료까지 폴링 (2분 타임아웃, 1초 간격) const LYRIC_POLL_TIMEOUT = 2 * 60 * 1000; // 2분 const LYRIC_POLL_INTERVAL = 1000; // 1초 export async function waitForLyricComplete( taskId: string, onStatusChange?: (status: string) => void ): Promise { const startTime = Date.now(); // 재귀적으로 폴링하는 방식으로 변경 (async/await 제대로 동작) const poll = async (): Promise => { // 2분 타임아웃 체크 if (Date.now() - startTime > LYRIC_POLL_TIMEOUT) { throw new Error('TIMEOUT'); } try { const statusResponse = await getLyricStatus(taskId); onStatusChange?.(statusResponse.status); if (statusResponse.status === 'completed') { // 완료되면 상세 조회로 가사 가져오기 const detailResponse = await getLyricDetail(taskId); return detailResponse; } else if (statusResponse.status === 'failed') { throw new Error(statusResponse.error_message || '가사 생성에 실패했습니다.'); } // processing은 대기 후 재시도 await new Promise(resolve => setTimeout(resolve, LYRIC_POLL_INTERVAL)); return poll(); } catch (error) { throw error; } }; return poll(); } // 노래 생성 API (task_id는 URL 경로에 포함) export async function generateSong(taskId: string, request: SongGenerateRequest): Promise { const response = await authenticatedFetch(`${API_URL}/song/generate/${taskId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(request), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 노래 상태 조회 API (Suno Polling) export async function getSongStatus(songId: string): Promise { const response = await authenticatedFetch(`${API_URL}/song/status/${songId}`, { method: 'GET', }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 노래 다운로드 API export async function downloadSong(taskId: string): Promise { const response = await authenticatedFetch(`${API_URL}/song/download/${taskId}`, { method: 'GET', }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 노래 생성 완료까지 폴링 (5분 타임아웃, 3초 간격) // Suno API 상태: PENDING, processing, SUCCESS, failed, error const SONG_POLL_TIMEOUT = 5 * 60 * 1000; // 5분 const SONG_POLL_INTERVAL = 3000; // 3초 const SONG_URL_RETRY_DELAY = 4000; // SUCCESS인데 song_result_url 없을 때 재요청 대기 시간 (4초) export async function waitForSongComplete( songId: string, onStatusChange?: (status: string) => void ): Promise { const startTime = Date.now(); const poll = async (): Promise => { // 5분 타임아웃 체크 if (Date.now() - startTime > SONG_POLL_TIMEOUT) { throw new Error('TIMEOUT'); } try { const response = await getSongStatus(songId); onStatusChange?.(response.status); // SUCCESS: Suno API 노래 생성 완료 if (response.status === 'SUCCESS' && response.success) { // song_result_url이 있으면 완료 if (response.song_result_url) { return response; } // song_result_url이 없으면 4초 후 재요청 await new Promise(resolve => setTimeout(resolve, SONG_URL_RETRY_DELAY)); return poll(); } // failed 또는 error: Suno API 노래 생성 실패 if (response.status === 'failed' || response.status === 'error') { throw new Error(response.error_message || '노래 생성에 실패했습니다.'); } // PENDING, processing 등은 대기 후 재시도 await new Promise(resolve => setTimeout(resolve, SONG_POLL_INTERVAL)); return poll(); } catch (error) { throw error; } }; return poll(); } // 영상 생성 API export async function generateVideo(taskId: string, orientation: 'vertical' | 'horizontal' = 'vertical'): Promise { const response = await authenticatedFetch(`${API_URL}/video/generate/${taskId}?orientation=${orientation}`, { method: 'GET', }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 영상 상태 확인 API export async function getVideoStatus(taskId: string): Promise { const response = await authenticatedFetch(`${API_URL}/video/status/${taskId}`, { method: 'GET', }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 영상 다운로드(결과 조회) API // export async function downloadVideo(taskId: string): Promise { // const response = await fetch(`${API_URL}/video/download/${taskId}`, { // method: 'GET', // headers: { // ...getAuthHeader(), // }, // }); // if (!response.ok) { // throw new Error(`HTTP error! status: ${response.status}`); // } // return response.json(); // } // 비디오 목록 조회 API export async function getVideosList(page: number = 1, pageSize: number = 10): Promise { const response = await authenticatedFetch(`${API_URL}/archive/videos/?page=${page}&page_size=${pageSize}&_t=${Date.now()}`, { method: 'GET', headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', }, }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 비디오 삭제 API export async function deleteVideo(taskId: string): Promise { const response = await authenticatedFetch(`${API_URL}/archive/videos/delete/${taskId}`, { method: 'DELETE', }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } } // 이미지 업로드 API (multipart/form-data) // 타임아웃: 5분 (많은 이미지 업로드 시 시간이 오래 걸릴 수 있음) const IMAGE_UPLOAD_TIMEOUT = 5 * 60 * 1000; export async function uploadImages( imageUrls: ImageUrlItem[], files: File[] ): Promise { const formData = new FormData(); // URL 이미지들을 images_json으로 전달 if (imageUrls.length > 0) { formData.append('images_json', JSON.stringify(imageUrls)); } // 파일들을 files로 전달 files.forEach((file) => { formData.append('files', file); }); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), IMAGE_UPLOAD_TIMEOUT); try { const response = await authenticatedFetch(`${API_URL}/image/upload/blob`, { method: 'POST', body: formData, signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === 'AbortError') { throw new Error('이미지 업로드 시간이 초과되었습니다. 다시 시도해주세요.'); } throw error; } } // 영상 생성 완료까지 폴링 (10분 타임아웃, 3초 간격) const VIDEO_POLL_TIMEOUT = 10 * 60 * 1000; // 10분 const VIDEO_POLL_INTERVAL = 3000; // 3초 export async function waitForVideoComplete( taskId: string, onStatusChange?: (status: string) => void ): Promise { const startTime = Date.now(); // 재귀적으로 폴링하는 방식으로 변경 (async/await 제대로 동작) const poll = async (): Promise => { // 10분 타임아웃 체크 if (Date.now() - startTime > VIDEO_POLL_TIMEOUT) { throw new Error('TIMEOUT'); } try { const statusResponse = await getVideoStatus(taskId); // render_data.status를 전달 (planned, waiting, transcribing, rendering, succeeded, failed) const renderStatus = statusResponse.render_data?.status; onStatusChange?.(renderStatus || statusResponse.status); // render_data.status가 "succeeded"일 때만 완료 if (renderStatus === 'succeeded') { return statusResponse; } else if (renderStatus === 'failed' || statusResponse.status === 'FAILED' || statusResponse.status === 'failed') { throw new Error(statusResponse.error_message || 'Video generation failed'); } // pending, rendering 등은 대기 후 재시도 await new Promise(resolve => setTimeout(resolve, VIDEO_POLL_INTERVAL)); return poll(); } catch (error) { throw error; } }; return poll(); } // ============================================ // 카카오 인증 API // ============================================ // 토큰 저장 키 const ACCESS_TOKEN_KEY = 'castad_access_token'; const REFRESH_TOKEN_KEY = 'castad_refresh_token'; // 토큰 저장 export function saveTokens(accessToken: string, refreshToken: string) { localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); } // 토큰 가져오기 export function getAccessToken(): string | null { return localStorage.getItem(ACCESS_TOKEN_KEY); } export function getRefreshToken(): string | null { return localStorage.getItem(REFRESH_TOKEN_KEY); } // 토큰 삭제 export function clearTokens() { localStorage.removeItem(ACCESS_TOKEN_KEY); localStorage.removeItem(REFRESH_TOKEN_KEY); } // 인증 헤더 생성 function getAuthHeader(): HeadersInit { const token = getAccessToken(); return token ? { 'Authorization': `Bearer ${token}` } : {}; } // 토큰 갱신 중복 방지를 위한 플래그 let isRefreshing = false; let refreshPromise: Promise | null = null; // 401 에러 시 자동으로 토큰 갱신 후 재요청하는 래퍼 함수 async function authenticatedFetch( url: string, options: RequestInit = {} ): Promise { // 인증 헤더 추가 const headers = { ...options.headers, ...getAuthHeader(), }; let response = await fetch(url, { ...options, headers }); // 401 에러 시 토큰 갱신 시도 if (response.status === 401) { try { // 이미 갱신 중이면 기존 Promise 재사용 (중복 요청 방지) if (!isRefreshing) { isRefreshing = true; refreshPromise = refreshAccessToken(); } await refreshPromise; isRefreshing = false; refreshPromise = null; // 새 토큰으로 재요청 const newHeaders = { ...options.headers, ...getAuthHeader(), }; response = await fetch(url, { ...options, headers: newHeaders }); } catch (refreshError) { isRefreshing = false; refreshPromise = null; // 토큰 갱신 실패 시 로그인 페이지로 리다이렉트할 수 있음 console.error('Token refresh failed:', refreshError); throw refreshError; } } return response; } // 카카오 로그인 URL 획득 export async function getKakaoLoginUrl(): Promise { const response = await fetch(`${API_URL}/user/auth/kakao/login`, { method: 'GET', }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 카카오 콜백 처리 (인가 코드로 JWT 토큰 발급) // 1. callback 호출 후 2. verify로 토큰 발급 export async function kakaoCallback(code: string): Promise { // 1단계: 콜백 처리 const callbackResponse = await fetch(`${API_URL}/user/auth/kakao/callback?code=${encodeURIComponent(code)}`, { method: 'GET', }); if (!callbackResponse.ok) { throw new Error(`Callback HTTP error! status: ${callbackResponse.status}`); } // 2단계: 코드 검증 및 토큰 발급 const verifyResponse = await fetch(`${API_URL}/user/auth/kakao/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ code }), }); if (!verifyResponse.ok) { throw new Error(`Verify HTTP error! status: ${verifyResponse.status}`); } const data: KakaoCallbackResponse = await verifyResponse.json(); // 토큰 저장 saveTokens(data.access_token, data.refresh_token); return data; } // Access Token 갱신 export async function refreshAccessToken(): Promise { const refreshToken = getRefreshToken(); if (!refreshToken) { throw new Error('No refresh token available'); } const response = await fetch(`${API_URL}/user/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!response.ok) { // 리프레시 토큰도 만료된 경우 토큰 삭제 if (response.status === 401) { clearTokens(); } throw new Error(`HTTP error! status: ${response.status}`); } const data: TokenRefreshResponse = await response.json(); // 새 액세스 토큰 저장 const currentRefreshToken = getRefreshToken(); if (currentRefreshToken) { saveTokens(data.access_token, currentRefreshToken); } return data; } // 로그아웃 export async function logout(): Promise { const response = await authenticatedFetch(`${API_URL}/user/auth/logout`, { method: 'POST', }); // 응답과 관계없이 로컬 토큰 삭제 clearTokens(); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } } // 모든 기기에서 로그아웃 export async function logoutAll(): Promise { const response = await authenticatedFetch(`${API_URL}/user/auth/logout/all`, { method: 'POST', }); // 응답과 관계없이 로컬 토큰 삭제 clearTokens(); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } } // 현재 사용자 정보 조회 export async function getUserMe(): Promise { const response = await authenticatedFetch(`${API_URL}/user/auth/me`, { method: 'GET', }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 로그인 여부 확인 export function isLoggedIn(): boolean { return !!getAccessToken(); } // ============================================ // 숙소 검색 & 자동완성 API // ============================================ export interface AccommodationSearchItem { address: string; roadAddress: string; title: string; } export interface AccommodationSearchResponse { count: number; items: AccommodationSearchItem[]; query: string; } export interface AutocompleteRequest { address: string; roadAddress: string; title: string; } // 숙소 검색 API (업체명 자동완성용) export async function searchAccommodation(query: string): Promise { const response = await authenticatedFetch(`${API_URL}/search/accommodation?query=${encodeURIComponent(query)}`, { method: 'GET', }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 자동완성 API (업체 정보로 크롤링) export async function autocomplete(request: AutocompleteRequest): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT); try { const response = await authenticatedFetch(`${API_URL}/autocomplete`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(request), signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === 'AbortError') { throw new Error('자동완성 요청 시간이 초과되었습니다. 다시 시도해주세요.'); } throw error; } }