o2o-castad-frontend/src/utils/api.ts

1521 lines
47 KiB
TypeScript

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<CrawlingResponse> {
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<LyricGenerateResponse> {
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<LyricStatusResponse> {
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<LyricDetailResponse> {
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<LyricDetailResponse> {
const startTime = Date.now();
// 재귀적으로 폴링하는 방식으로 변경 (async/await 제대로 동작)
const poll = async (): Promise<LyricDetailResponse> => {
// 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<SongGenerateResponse> {
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<SongStatusResponse> {
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<SongDownloadResponse> {
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<SongStatusResponse> {
const startTime = Date.now();
const poll = async (): Promise<SongStatusResponse> => {
// 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<SubtitleStatusResponse> {
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<SubtitleStatusResponse> {
const startTime = Date.now();
const poll = async (): Promise<SubtitleStatusResponse> => {
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<VideoGenerateResponse> {
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<void> {
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<string, string> = {};
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<void> {
try {
let utm: Record<string, string> = {};
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<VideoStatusResponse> {
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<VideoDownloadResponse> {
// 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<VideosListResponse> {
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<number | null> {
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<void> {
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<VideosListResponse> {
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<VideoDetailItem> {
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<SsulDetailItem> {
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<void> {
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<CommentsResponse> {
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<CommentItem> {
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<void> {
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<LikeToggleResponse> {
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<ImageUploadResponse> {
return uploadImagesSequentially(imageUrls, files, postImageUpload);
}
async function postImageUpload(formData: FormData): Promise<ImageUploadResponse> {
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<VideoStatusResponse> {
const startTime = Date.now();
// 재귀적으로 폴링하는 방식으로 변경 (async/await 제대로 동작)
const poll = async (): Promise<VideoStatusResponse> => {
// 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<TokenRefreshResponse> | null = null;
// 로그인 페이지로 리다이렉트 (토큰 만료 시)
function redirectToLogin() {
// 토큰 삭제
clearTokens();
// localStorage 정리 (생성 중이던 상태까지 전부 — 재로그인 시 잔류 방지)
clearSessionStorage();
// 홈으로 리다이렉트
window.location.href = '/';
}
// 401 에러 시 자동으로 토큰 갱신 후 재요청하는 래퍼 함수
export async function authenticatedFetch(
url: string,
options: RequestInit = {},
{ redirectOnAuthFail = true }: { redirectOnAuthFail?: boolean } = {}
): Promise<Response> {
// 인증 헤더 + 캐시 방지 헤더 추가
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<KakaoLoginUrlResponse> {
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<KakaoCallbackResponse> {
// 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<TokenRefreshResponse> {
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<void> {
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<void> {
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<UserMeResponse> {
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<UserCreditsResponse> {
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<void> {
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에 <b> 태그와 &amp; 같은 HTML 엔티티를 함께 내려주므로
// 태그 제거 후 엔티티까지 디코딩해야 "&amp;" 같은 원문이 그대로 노출되지 않는다.
// 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<AccommodationSearchResponse> {
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<CrawlingResponse> {
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<CrawlingResponse> {
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<void> {
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<void> {
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<YouTubeConnectResponse> {
const response = await authenticatedFetch(`${API_URL}/social/oauth/youtube/connect`, {
method: 'GET',
});
await handleSocialResponse(response);
return response.json();
}
// 연결된 소셜 계정 목록 조회
export async function getSocialAccounts(): Promise<SocialAccountsResponse> {
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<SocialAccountResponse> {
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<SocialDisconnectResponse> {
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<YTAutoSeoResponse> {
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<SocialUploadResponse> {
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<SocialUploadStatusResponse> {
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<SocialUploadStatusResponse> {
const startTime = Date.now();
const poll = async (): Promise<SocialUploadStatusResponse> => {
// 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<import('../types/api').UploadHistoryResponse> {
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<SsulCreateResponse> {
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<SsulTaskStatus> {
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<SsulTaskStatus | null> {
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<SsulTaskStatus> {
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);
}