919 lines
27 KiB
TypeScript
919 lines
27 KiB
TypeScript
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<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) {
|
|
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 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();
|
|
}
|
|
|
|
// 영상 상태 확인 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 (multipart/form-data)
|
|
// 타임아웃: 5분 (많은 이미지 업로드 시 시간이 오래 걸릴 수 있음)
|
|
const IMAGE_UPLOAD_TIMEOUT = 5 * 60 * 1000;
|
|
|
|
export async function uploadImages(
|
|
imageUrls: ImageUrlItem[],
|
|
files: File[]
|
|
): Promise<ImageUploadResponse> {
|
|
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<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 정리
|
|
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<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) {
|
|
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<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;
|
|
}
|
|
|
|
// 로그아웃
|
|
export async function logout(): Promise<void> {
|
|
const response = await authenticatedFetch(`${API_URL}/user/auth/logout`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
// 응답과 관계없이 로컬 토큰 삭제
|
|
clearTokens();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
}
|
|
|
|
// 모든 기기에서 로그아웃
|
|
export async function logoutAll(): Promise<void> {
|
|
const response = await authenticatedFetch(`${API_URL}/user/auth/logout/all`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
// 응답과 관계없이 로컬 토큰 삭제
|
|
clearTokens();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
}
|
|
|
|
// 현재 사용자 정보 조회
|
|
export async function getUserMe(): Promise<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 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<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;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// 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();
|
|
}
|