import { clearSessionStorage } from './storageKeys'; import { CrawlingResponse, LyricGenerateRequest, LyricGenerateResponse, LyricStatusResponse, LyricDetailResponse, SongGenerateRequest, SongGenerateResponse, SongStatusResponse, SongDownloadResponse, SubtitleStatusResponse, VideoGenerateResponse, VideoStatusResponse, VideoDownloadResponse, VideosListResponse, ImageUrlItem, ImageUploadResponse, KakaoLoginUrlResponse, KakaoCallbackResponse, TokenRefreshResponse, UserMeResponse, YouTubeConnectResponse, SocialAccountsResponse, SocialAccountResponse, SocialDisconnectResponse, SocialUploadRequest, SocialUploadResponse, SocialUploadStatusResponse, TokenExpiredErrorResponse, YTAutoSeoRequest, YTAutoSeoResponse, UserCreditsResponse, VideoDetailItem, CommentsResponse, CommentItem, ContentType, LikeToggleResponse, } from '../types/api'; import { uploadImagesSequentially } from './imageUpload.ts'; 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) { const errorBody = await response.json().catch(() => null); console.error('[generateSong] 422 detail:', JSON.stringify(errorBody)); 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 getSubtitleStatus(taskId: string): Promise { const response = await authenticatedFetch(`${API_URL}/lyric/subtitle/status/${taskId}`, { method: 'GET', }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 자막 완료까지 폴링 (5초 간격, 10분 타임아웃) const SUBTITLE_POLL_INTERVAL = 5000; const SUBTITLE_POLL_TIMEOUT = 5 * 60 * 1000; export async function waitForSubtitleComplete( taskId: string, onStatusChange?: (status: string) => void ): Promise { const startTime = Date.now(); const poll = async (): Promise => { if (Date.now() - startTime > SUBTITLE_POLL_TIMEOUT) { throw new Error('TIMEOUT'); } const response = await getSubtitleStatus(taskId); onStatusChange?.(response.status); if (response.status === 'completed') { return response; } if (response.status === 'failed' || response.status === 'error') { throw new Error(response.message || '자막 생성에 실패했습니다.'); } await new Promise(resolve => setTimeout(resolve, SUBTITLE_POLL_INTERVAL)); return poll(); }; 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(); } // ============================================================ // Meta 전환 추적 // ============================================================ // Meta 픽셀이 심는 1st-party 쿠키 값 읽기 (_fbc/_fbp) function getCookieValue(name: string): string | null { const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)); return match ? decodeURIComponent(match[1]) : null; } // URL의 fbclid로 조립해둔 fbc 폴백 값을 보관하는 localStorage 키 // UTM과 분리해 두어야 가입 완료 후 UTM을 정리해도 이후 이벤트에서 계속 쓸 수 있다. const FBC_STORAGE_KEY = 'castad_fbc'; // Meta 클릭 ID(fbc) 조회 // 픽셀이 심은 _fbc 쿠키를 우선 사용하고, 없으면 랜딩 시 fbclid로 조립해둔 값을 쓴다. // 애드블로커·쿠키 정리 등으로 쿠키가 유실돼도 광고 클릭 기여를 잃지 않기 위한 폴백이다. function getFbc(): string | null { const cookie = getCookieValue('_fbc'); if (cookie) return cookie; try { return localStorage.getItem(FBC_STORAGE_KEY); } catch { return null; } } // 첫 영상 생성 완료(FirstVideoCreated) 전환 이벤트 발화 API // 서버가 first_video_created_at null 여부로 계정당 최초 1회를 판정하고, // fired=true일 때만 브라우저 픽셀(fbq)을 동일 event_id로 발화해 중복제거를 보장한다. export async function trackFirstVideoCreated(): Promise { try { const response = await authenticatedFetch(`${API_URL}/tracking/meta/first-video-created`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ fbc: getFbc(), fbp: getCookieValue('_fbp'), event_source_url: window.location.href, }), }); if (!response.ok) return; const data: { fired: boolean; event_id: string | null } = await response.json(); const fbq = (window as unknown as { fbq?: (...args: unknown[]) => void }).fbq; if (data.fired && data.event_id && typeof fbq === 'function') { // 서버 CAPI 이벤트와 동일한 event_id로 발화 → Meta가 중복제거 fbq('trackCustom', 'FirstVideoCreated', {}, { eventID: data.event_id }); } } catch (error) { // 전환 추적 실패는 서비스 기능에 영향을 주지 않도록 무시 console.error('[Tracking] FirstVideoCreated failed:', error); } } // UTM 파라미터 localStorage 키 (first-touch 보존) const UTM_STORAGE_KEY = 'castad_utm'; const UTM_PARAM_NAMES = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const; // 이벤트 중복제거용 UUID 생성 // crypto.randomUUID는 보안 컨텍스트(https/localhost) 전용이라 미지원 환경 폴백 포함 function generateEventId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } // fbq 전역 함수 안전 접근 function getFbq(): ((...args: unknown[]) => void) | null { const fbq = (window as unknown as { fbq?: (...args: unknown[]) => void }).fbq; return typeof fbq === 'function' ? fbq : null; } // 랜딩 진입 URL에서 광고 유입 정보(UTM, fbclid)를 localStorage에 보관한다. export function storeUtmFromUrl(): void { try { const params = new URLSearchParams(window.location.search); // UTM: 이미 저장된 값이 있으면 덮어쓰지 않아 최초 유입 경로(first-touch)를 보존 if (!localStorage.getItem(UTM_STORAGE_KEY)) { const utm: Record = {}; for (const name of UTM_PARAM_NAMES) { const value = params.get(name); if (value) utm[name] = value; } if (Object.keys(utm).length > 0) { localStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(utm)); } } // fbclid: 광고 클릭 기여는 최신 클릭이 가져가므로 새 값이 오면 덮어쓴다 // (Meta 픽셀의 _fbc 쿠키도 동일하게 last-touch로 동작) // 형식: fb.{subdomainIndex}.{creationTime(ms)}.{fbclid} const fbclid = params.get('fbclid'); if (fbclid) { localStorage.setItem(FBC_STORAGE_KEY, `fb.1.${Date.now()}.${fbclid}`); } } catch (error) { console.error('[Tracking] storeUtmFromUrl failed:', error); } } // 랜딩 진입(ViewContent) 이벤트 발화 — 비로그인 시점이므로 브라우저 픽셀 단독 export function trackViewContent(): void { try { const fbq = getFbq(); if (fbq) { fbq('track', 'ViewContent', {}, { eventID: generateEventId() }); console.log('[Tracking] ViewContent fired'); } else { console.warn('[Tracking] ViewContent skipped: fbq not loaded'); } } catch (error) { const detail = error instanceof Error ? `${error.name}: ${error.message}\n${error.stack}` : String(error); console.error('[Tracking] ViewContent failed:', detail); } } // 회원가입 완료(CompleteRegistration) 전환 이벤트 발화 API // 서버가 registration_tracked_at null 여부로 계정당 최초 1회를 판정하고 UTM을 함께 기록한다. // fired=true일 때만 브라우저 픽셀(fbq)을 동일 event_id로 발화해 중복제거를 보장한다. export async function trackCompleteRegistration(): Promise { try { let utm: Record = {}; try { utm = JSON.parse(localStorage.getItem(UTM_STORAGE_KEY) ?? '{}'); } catch { utm = {}; } const response = await authenticatedFetch(`${API_URL}/tracking/meta/complete-registration`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ fbc: getFbc(), fbp: getCookieValue('_fbp'), event_source_url: window.location.href, utm_source: utm.utm_source ?? null, utm_medium: utm.utm_medium ?? null, utm_campaign: utm.utm_campaign ?? null, utm_content: utm.utm_content ?? null, utm_term: utm.utm_term ?? null, }), }); if (!response.ok) return; const data: { fired: boolean; event_id: string | null } = await response.json(); if (data.fired && data.event_id) { const fbq = getFbq(); if (fbq) { // 서버 CAPI 이벤트와 동일한 event_id로 발화 → Meta가 중복제거 fbq('track', 'CompleteRegistration', {}, { eventID: data.event_id }); } // 가입 어트리뷰션 저장 완료 → 보관하던 UTM 제거 localStorage.removeItem(UTM_STORAGE_KEY); } } catch (error) { // 전환 추적 실패는 서비스 기능에 영향을 주지 않도록 무시 console.error('[Tracking] CompleteRegistration failed:', error); } } // 영상 상태 확인 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 (ADO2 콘텐츠 갤러리용) export async function getAllVideos( page: number = 1, pageSize: number = 20, sortBy: 'created_at' | 'like_count' | 'comment_count' = 'created_at', storeName: string = '', order: 'desc' | 'asc' = 'desc', region: string = '', ): Promise { const params = new URLSearchParams({ page: String(page), page_size: String(pageSize), sort_by: sortBy, order, }); if (storeName.trim()) params.set('store_name', storeName.trim()); if (region.trim()) params.set('region', region.trim()); const response = await authenticatedFetch(`${API_URL}/video/all?${params.toString()}`, { method: 'GET', }, { redirectOnAuthFail: false }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 단일 영상 상세 조회 API export async function getVideoById(videoId: string): Promise { const response = await authenticatedFetch(`${API_URL}/video/${videoId}`, { method: 'GET', }, { redirectOnAuthFail: false }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 썰박스 콘텐츠 공개 상세 (비로그인 허용 — 공유 링크 /ssul/{id} 가 사용) export interface SsulDetailItem { content_id: number; scenario: string; video_url: string; store_name: string | null; region: string | null; created_at: string; like_count: number; is_liked_by_me: boolean; } export async function getSsulContentById(contentId: string): Promise { const response = await authenticatedFetch(`${API_URL}/ssul/${contentId}`, { method: 'GET', }, { redirectOnAuthFail: false }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 썰박스 콘텐츠 소프트 삭제. // ⚠️ deleteVideo(= /archive/videos/{id}, Video.id 기준)와 절대 혼용하지 말 것 — // id 가 겹치므로 엉뚱한 ADO2 영상이 지워진다. 썰박스는 반드시 이 함수로. export async function deleteSsulContent(contentId: number): Promise { const response = await authenticatedFetch(`${API_URL}/ssul/${contentId}`, { method: 'DELETE', }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } } // 댓글 목록 조회 // contentType: video.id 와 ssul_content.id 가 겹치므로 종류를 함께 보낸다 (기본 'video') export async function getVideoComments( videoId: string, page: number = 1, pageSize: number = 20, contentType: ContentType = 'video' ): Promise { const response = await authenticatedFetch( `${API_URL}/comment/video/${videoId}?page=${page}&page_size=${pageSize}&type=${contentType}`, { method: 'GET', }, { redirectOnAuthFail: false } ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 댓글 작성 export async function postVideoComment( videoId: string, content: string, nickname?: string, parentId?: number, contentType: ContentType = 'video' ): Promise { const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?type=${contentType}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, nickname: nickname || '익명', parent_id: parentId ?? null }), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 댓글 삭제 export async function deleteComment(commentId: number): Promise { const response = await authenticatedFetch(`${API_URL}/comment/${commentId}`, { method: 'DELETE', }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } } // 좋아요 토글 export async function toggleVideoLike( videoId: string, // video.id 와 ssul_content.id 가 겹치므로 종류를 함께 보내야 한다. // 기본값 'video' 라 기존 호출부는 수정이 없다. contentType: ContentType = 'video' ): Promise { const response = await authenticatedFetch( `${API_URL}/video/${videoId}/like?type=${contentType}`, { method: 'POST', } ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 이미지 업로드 API (multipart/form-data) // 타임아웃: 5분 (많은 이미지 업로드 시 시간이 오래 걸릴 수 있음) const IMAGE_UPLOAD_TIMEOUT = 5 * 60 * 1000; export async function uploadImages( imageUrls: ImageUrlItem[], files: File[] ): Promise { return uploadImagesSequentially(imageUrls, files, postImageUpload); } async function postImageUpload(formData: FormData): Promise { 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, }); if (!response.ok) { if (response.status === 413) { throw new Error('이미지 파일이 너무 커서 업로드할 수 없습니다. 더 작은 이미지를 선택해주세요.'); } throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw new Error('이미지 업로드 시간이 초과되었습니다. 다시 시도해주세요.'); } throw error; } finally { clearTimeout(timeoutId); } } // 영상 생성 완료까지 폴링 (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 정리 (생성 중이던 상태까지 전부 — 재로그인 시 잔류 방지) clearSessionStorage(); // 홈으로 리다이렉트 window.location.href = '/'; } // 401 에러 시 자동으로 토큰 갱신 후 재요청하는 래퍼 함수 export async function authenticatedFetch( url: string, options: RequestInit = {}, { redirectOnAuthFail = true }: { redirectOnAuthFail?: boolean } = {} ): 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) { const errorBody = await response.json().catch(() => null); const errorCode = errorBody?.detail?.code; // 애초에 토큰이 없던 익명 요청(공개 화면)까지 튕기면 안 되므로 별도 처리 const isMissingToken = errorCode === 'MISSING_TOKEN'; if (errorCode !== 'TOKEN_EXPIRED') { // INVALID_TOKEN 등 갱신으로 해결 불가한 경우 즉시 로그인 이동 if (redirectOnAuthFail && !isMissingToken) { redirectToLogin(); } throw new Error(errorCode ?? 'Unauthorized'); } try { if (!refreshPromise) { refreshPromise = refreshAccessToken().finally(() => { 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); if (redirectOnAuthFail) { 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(); clearSessionStorage(); } // 로그아웃 export async function logout(): Promise { const refreshToken = getRefreshToken(); try { if (refreshToken) { const response = await authenticatedFetch(`${API_URL}/user/auth/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } } } finally { // 응답과 관계없이 로컬 데이터 전체 삭제 clearAllLocalData(); } } // 모든 기기에서 로그아웃 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 async function getUserCredits(): Promise { const response = await authenticatedFetch(`${API_URL}/user/auth/me/credits`, { method: 'GET', }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // 크레딧 충전 요청 export async function requestCreditCharge(amount: number, note: string): Promise { const response = await authenticatedFetch(`${API_URL}/user/credits/charge-requests`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ requested_amount: amount, message: note }), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } } // 로그인 여부 확인 export function isLoggedIn(): boolean { return !!getAccessToken(); } // ============================================ // 숙소 검색 & 자동완성 API // ============================================ export interface AccommodationSearchItem { address: string; roadAddress: string; title: string; } // 네이버 검색 API는 title에 태그와 & 같은 HTML 엔티티를 함께 내려주므로 // 태그 제거 후 엔티티까지 디코딩해야 "&" 같은 원문이 그대로 노출되지 않는다. // textarea는 DOM에 붙이지 않고 content model상 스크립트를 실행하지 않으므로 엔티티 디코딩 용도로 안전하다. export const cleanSearchTitle = (title: string): string => { const withoutTags = title.replace(/<[^>]*>/g, ''); const textarea = document.createElement('textarea'); textarea.innerHTML = withoutTags; return textarea.value; }; 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; } } // 업체명·주소 직접 입력으로 마케팅 분석 export async function marketingAnalysis(storeName: string, address: string, category = ''): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT); try { const response = await authenticatedFetch(`${API_URL}/marketing`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ store_name: storeName, address, category }), 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(); } // ============================================================================ // 썰박스 (Ssulbox) // ============================================================================ export interface SsulCreateRequest { scenario: string; /** * 업장명 또는 네이버 지도 링크. * * 자동완성으로 고른 경우 **업장명**을 보낸다 — place URL 해석은 서버가 * ADO2 와 동일한 경로(NvMapPwScraper)로 처리한다. 링크를 직접 붙여넣었으면 * 그대로 보낸다. */ input: string; scenes?: number; seconds?: number; /** 자동완성으로 고른 경우에만. 목록 표시·필터·place URL 해석에 쓰인다 */ store_name?: string; /** 지역 추출용(도로명). 백엔드는 이 값을 저장하지 않는다 */ road_address?: string; /** 지역 추출용(지번). 도로명에서 시/군 추출이 실패할 때의 폴백 */ address?: string; } export interface SsulCreateResponse { id: number; status: string; poll_interval_seconds?: number; } export async function createSsulJob( request: SsulCreateRequest ): Promise { const response = await authenticatedFetch(`${API_URL}/ssul/create`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), }); if (response.status === 402) { throw new InsufficientCreditError(); } if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } /** 크레딧 부족(402). 호출부가 충전 화면으로 유도할 수 있게 별도 타입으로 던진다 */ export class InsufficientCreditError extends Error { constructor(message = '크레딧이 부족합니다.') { super(message); this.name = 'InsufficientCreditError'; } } export interface SsulTaskStatus { id: number; scenario: string; status: 'queued' | 'running' | 'done' | 'error'; /** 0~4. 0=준비, 4=영상 합성 완료 */ step: number; error: string | null; video_url: string | null; /** * 대상 업장명. `status === 'done'` 이면 서버가 채워 보낸다. * 생성 중에는 비어 있을 수 있다 — 행은 요청 즉시 만들어지고 업장명은 그 뒤에 확정된다. */ store_name: string; created_at: string; } /** 폴링 간격. castad 의 waitForVideoComplete 와 동일하게 맞춘다 */ const SSUL_POLL_INTERVAL = 3000; /** 생성이 수 분~십수 분 걸리므로 넉넉히 */ const SSUL_POLL_TIMEOUT = 30 * 60 * 1000; /** 생성 잡 단건 조회 */ export async function getSsulTask(taskId: number): Promise { const response = await authenticatedFetch(`${API_URL}/ssul/tasks/${taskId}`); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); return response.json(); } /** * 진행 중인 내 잡 조회 (새로고침·새 탭 복구용). * 진행 중인 것이 없으면 null. */ export async function getActiveSsulTask(): Promise { try { const response = await authenticatedFetch(`${API_URL}/ssul/tasks/active`); if (!response.ok) return null; const data = await response.json(); return data.items?.[0] ?? null; } catch { return null; } } /** * 생성 완료까지 폴링한다. castad 의 waitForVideoComplete 와 같은 재귀 패턴. * * SSE 대신 폴링을 쓰는 이유는 계획서 참조 — 요약하면 EventSource 가 * `authenticatedFetch` 의 401 자동 갱신을 못 타고, 스트림이 DB 커넥션을 * 점유하며, 리버스 프록시 버퍼링에 취약하기 때문이다. * * @param shouldStop 매 틱마다 확인. true 면 조용히 중단한다(언마운트·로그아웃). */ export async function waitForSsulComplete( taskId: number, onProgress?: (task: SsulTaskStatus) => void, shouldStop?: () => boolean, startedAt: number = Date.now() ): Promise { if (shouldStop?.()) throw new Error('CANCELLED'); if (Date.now() - startedAt > SSUL_POLL_TIMEOUT) throw new Error('TIMEOUT'); const task = await getSsulTask(taskId); onProgress?.(task); if (task.status === 'done') return task; if (task.status === 'error') throw new Error(task.error ?? 'SSUL_FAILED'); await new Promise((resolve) => setTimeout(resolve, SSUL_POLL_INTERVAL)); return waitForSsulComplete(taskId, onProgress, shouldStop, startedAt); }