import { CrawlingResponse, LyricGenerateRequest, LyricGenerateResponse, LyricStatusResponse, LyricDetailResponse, SongGenerateRequest, SongGenerateResponse, SongStatusResponse, SongDownloadResponse, VideoGenerateResponse, VideoStatusResponse, VideoDownloadResponse, VideosListResponse, ImageUrlItem, ImageUploadResponse, KakaoLoginUrlResponse, KakaoCallbackResponse, TokenRefreshResponse, UserMeResponse, YouTubeConnectResponse, SocialAccountsResponse, SocialAccountResponse, SocialDisconnectResponse, SocialUploadRequest, SocialUploadResponse, SocialUploadStatusResponse, TokenExpiredErrorResponse, YTAutoSeoRequest, YTAutoSeoResponse, } from '../types/api'; export 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(); } // task_id로 video_id 조회 (소셜 업로드용) export async function getVideoIdByTaskId(taskId: string): Promise { try { const response = await getVideosList(1, 50); const found = response.items.find(v => v.task_id === taskId); return found?.video_id ?? null; } catch { return null; } } // 비디오 삭제 API (개별 비디오 삭제) export async function deleteVideo(videoId: number): Promise { const response = await authenticatedFetch(`${API_URL}/archive/videos/${videoId}`, { 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}` } : {}; } // 토큰 갱신 중복 방지를 위한 Promise (싱글톤 패턴) let refreshPromise: Promise | null = null; // 로그인 페이지로 리다이렉트 (토큰 만료 시) function redirectToLogin() { // 토큰 삭제 clearTokens(); // localStorage 정리 localStorage.removeItem('castad_view_mode'); localStorage.removeItem('castad_analysis_data'); localStorage.removeItem('castad_wizard_step'); localStorage.removeItem('castad_active_item'); // 홈으로 리다이렉트 window.location.href = '/'; } // 401 에러 시 자동으로 토큰 갱신 후 재요청하는 래퍼 함수 export async function authenticatedFetch( url: string, options: RequestInit = {} ): Promise { // 인증 헤더 + 캐시 방지 헤더 추가 const headers: HeadersInit = { ...options.headers, ...getAuthHeader(), 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', }; let response = await fetch(url, { ...options, headers }); // 401 에러 시 토큰 갱신 시도 if (response.status === 401) { try { // 이미 갱신 중이면 기존 Promise 재사용 (중복 요청 방지) // refreshPromise가 존재하면 재사용, 없으면 새로 생성 if (!refreshPromise) { refreshPromise = refreshAccessToken().finally(() => { // 성공/실패 상관없이 Promise 초기화 (다음 갱신을 위해) refreshPromise = null; }); } await refreshPromise; // 새 토큰으로 재요청 const newHeaders: HeadersInit = { ...options.headers, ...getAuthHeader(), 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', }; response = await fetch(url, { ...options, headers: newHeaders }); } catch (refreshError) { console.error('Token refresh failed:', refreshError); // 토큰 갱신 실패 시 로그인 페이지로 리다이렉트 redirectToLogin(); 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) { console.error('[Auth] No refresh token available'); throw new Error('No refresh token available'); } console.log('[Auth] Attempting to refresh access token...'); const response = await fetch(`${API_URL}/user/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!response.ok) { console.error(`[Auth] Token refresh failed with status: ${response.status}`); // 리프레시 토큰도 만료된 경우 토큰 삭제 if (response.status === 401 || response.status === 403) { console.log('[Auth] Refresh token expired, clearing tokens...'); clearTokens(); } throw new Error(`Token refresh failed: ${response.status}`); } const data: TokenRefreshResponse = await response.json(); console.log('[Auth] Token refresh successful'); // 새 액세스 토큰과 리프레시 토큰을 localStorage에 갱신 saveTokens(data.access_token, data.refresh_token); return data; } // 로컬 스토리지 전체 정리 function clearAllLocalData() { clearTokens(); localStorage.removeItem('castad_view_mode'); localStorage.removeItem('castad_analysis_data'); localStorage.removeItem('castad_wizard_step'); localStorage.removeItem('castad_active_item'); localStorage.removeItem('castad_song_task_id'); localStorage.removeItem('castad_image_task_id'); localStorage.removeItem('castad_song_generation'); localStorage.removeItem('castad_video_generation'); localStorage.removeItem('castad_video_ratio'); } // 로그아웃 export async function logout(): Promise { const response = await authenticatedFetch(`${API_URL}/user/auth/logout`, { method: 'POST', }); // 응답과 관계없이 로컬 데이터 전체 삭제 clearAllLocalData(); 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', }); // 응답과 관계없이 로컬 데이터 전체 삭제 clearAllLocalData(); 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; } } // ============================================ // Social OAuth TOKEN_EXPIRED 처리 // ============================================ // YouTube 등 소셜 플랫폼 토큰 만료 에러 클래스 export class TokenExpiredError extends Error { platform: string; reconnectUrl: string; constructor(response: TokenExpiredErrorResponse) { super(response.detail); this.name = 'TokenExpiredError'; this.platform = response.platform; this.reconnectUrl = response.reconnect_url; } } // Social API 응답에서 TOKEN_EXPIRED 에러를 감지하고 처리 async function handleSocialResponse(response: Response): Promise { if (!response.ok) { const body = await response.json().catch(() => null); if (body && body.code === 'TOKEN_EXPIRED') { throw new TokenExpiredError(body as TokenExpiredErrorResponse); } throw new Error(body?.detail || `HTTP error! status: ${response.status}`); } } // TOKEN_EXPIRED 발생 시 재연동 플로우 실행 export async function handleSocialReconnect(reconnectUrl: string): Promise { try { const response = await authenticatedFetch(`${API_URL}${reconnectUrl}`, { method: 'GET', }); if (!response.ok) { throw new Error(`재연동 요청 실패: ${response.status}`); } const data: { auth_url: string } = await response.json(); window.location.href = data.auth_url; } catch (error) { console.error('[Social] 재연동 처리 실패:', error); throw error; } } // ============================================ // Social OAuth API (YouTube, Instagram, Facebook) // ============================================ // YouTube 연결 URL 획득 export async function getYouTubeConnectUrl(): Promise { const response = await authenticatedFetch(`${API_URL}/social/oauth/youtube/connect`, { method: 'GET', }); await handleSocialResponse(response); return response.json(); } // 연결된 소셜 계정 목록 조회 export async function getSocialAccounts(): Promise { const response = await authenticatedFetch(`${API_URL}/social/oauth/accounts`, { method: 'GET', }); await handleSocialResponse(response); return response.json(); } // 특정 플랫폼 계정 조회 export async function getSocialAccountByPlatform(platform: 'youtube' | 'instagram' | 'facebook'): Promise { const response = await authenticatedFetch(`${API_URL}/social/oauth/accounts/${platform}`, { method: 'GET', }); await handleSocialResponse(response); return response.json(); } // 소셜 계정 연결 해제 (계정 ID로) export async function disconnectSocialAccount(accountId: number): Promise { const response = await authenticatedFetch(`${API_URL}/social/oauth/accounts/${accountId}`, { method: 'DELETE', }); await handleSocialResponse(response); return response.json(); } // ============================================ // Social Upload API (YouTube Video Upload) // ============================================ // YouTube Description API export async function getAutoSeoYoutube(request: YTAutoSeoRequest): Promise { const response = await authenticatedFetch(`${API_URL}/social/seo/youtube`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(request), }); await handleSocialResponse(response); return response.json(); } // YouTube 영상 업로드 시작 export async function uploadToSocial(request: SocialUploadRequest): Promise { const response = await authenticatedFetch(`${API_URL}/social/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(request), }); await handleSocialResponse(response); return response.json(); } // 업로드 상태 조회 export async function getUploadStatus(uploadId: string): Promise { const response = await authenticatedFetch(`${API_URL}/social/upload/${uploadId}/status`, { method: 'GET', }); await handleSocialResponse(response); return response.json(); } // 업로드 완료까지 폴링 (5분 타임아웃, 2초 간격) const UPLOAD_POLL_TIMEOUT = 5 * 60 * 1000; // 5분 const UPLOAD_POLL_INTERVAL = 2000; // 2초 export async function waitForUploadComplete( uploadId: string, onStatusChange?: (status: string, progress?: number) => void ): Promise { const startTime = Date.now(); const poll = async (): Promise => { // 5분 타임아웃 체크 if (Date.now() - startTime > UPLOAD_POLL_TIMEOUT) { throw new Error('TIMEOUT'); } try { const response = await getUploadStatus(uploadId); onStatusChange?.(response.status, response.upload_progress); if (response.status === 'completed') { return response; } else if (response.status === 'failed') { throw new Error(response.error_message || '업로드에 실패했습니다.'); } // pending, uploading은 대기 후 재시도 await new Promise(resolve => setTimeout(resolve, UPLOAD_POLL_INTERVAL)); return poll(); } catch (error) { throw error; } }; return poll(); } // 업로드 히스토리 조회 export async function getUploadHistory( tab: 'all' | 'completed' | 'scheduled' | 'failed' = 'all', options?: { year?: number; month?: number; platform?: string; page?: number; size?: number } ): Promise { const params = new URLSearchParams({ tab }); if (options?.year) params.set('year', String(options.year)); if (options?.month) params.set('month', String(options.month)); if (options?.platform) params.set('platform', options.platform); if (options?.page) params.set('page', String(options.page)); if (options?.size) params.set('size', String(options.size)); const response = await authenticatedFetch( `${API_URL}/social/upload/history?${params.toString()}`, { method: 'GET' } ); if (!response.ok) throw new Error('히스토리 조회 실패'); return response.json(); } // 예약 업로드 취소 export async function cancelUpload(uploadId: number): Promise<{ success: boolean; message: string }> { const response = await authenticatedFetch( `${API_URL}/social/upload/${uploadId}`, { method: 'DELETE' } ); if (!response.ok) throw new Error('취소 실패'); return response.json(); } // 업로드 재시도 export async function retryUpload(uploadId: number): Promise<{ success: boolean; message: string }> { const response = await authenticatedFetch( `${API_URL}/social/upload/${uploadId}/retry`, { method: 'POST' } ); if (!response.ok) throw new Error('재시도 실패'); return response.json(); }