1521 lines
47 KiB
TypeScript
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> 태그와 & 같은 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<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);
|
|
}
|