version19

master
jaehwang 2025-12-11 16:24:16 +09:00
parent d820394ccc
commit 07a3a1093b
59 changed files with 19645 additions and 2399 deletions

193
.env.example Normal file
View File

@ -0,0 +1,193 @@
# ============================================
# CaStAD v3.2.0 개발 환경 설정 파일
# ============================================
# 이 파일을 .env로 복사하여 사용하세요
# cp .env.example .env
# ============================================
# ============================================
# 🔧 기본 설정 (Basic Configuration)
# ============================================
# Node 환경 설정
# - development: 개발 모드 (상세 로그, 핫 리로딩)
# - production: 프로덕션 모드 (최적화, 보안 강화)
NODE_ENV=development
# 백엔드 서버 포트 (Express.js)
# - 기본값: 3001
# - Vite 프록시가 이 포트로 API 요청을 전달합니다
PORT=3001
# JWT 인증 시크릿 키
# - 사용자 인증 토큰 생성에 사용
# - 프로덕션에서는 반드시 복잡한 랜덤 문자열로 변경하세요!
# - 예: openssl rand -base64 32
JWT_SECRET=dev-secret-key-change-in-production
# URL 설정 (개발 환경)
# - FRONTEND_URL: 프론트엔드 URL (Vite 개발 서버)
# - BACKEND_URL: 백엔드 API URL
# - ALLOWED_ORIGINS: CORS 허용 도메인 (쉼표로 구분)
FRONTEND_URL=http://localhost:5173
BACKEND_URL=http://localhost:3001
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
# ============================================
# 🔑 필수 API 키 (Required APIs)
# ============================================
# 이 키들이 없으면 핵심 기능이 작동하지 않습니다
# Google Gemini AI API
# - 용도: AI 텍스트 생성, 마케팅 문구 작성, AI 매직 라이트
# - 발급: https://ai.google.dev/ (Google AI Studio)
# - 무료 할당량: 분당 60회 요청
# - 참고: 서버와 프론트엔드 모두에서 사용 (VITE_ 접두사 필요)
VITE_GEMINI_API_KEY=your-gemini-api-key
# Suno AI API
# - 용도: AI 음악 생성 (배경음악, 광고 음악)
# - 발급: https://suno.ai/ 또는 프록시 서비스 사용
# - 프록시 예시: https://github.com/gcui-art/suno-api
SUNO_API_KEY=your-suno-api-key
# ============================================
# 🎪 축제 연동 API (Festival Integration)
# ============================================
# 한국관광공사 Tour API
# - 용도: 전국 축제/행사 정보 조회, 지역별 축제 연동
# - 발급: https://api.visitkorea.or.kr/ (회원가입 후 키 발급)
# - 무료 할당량: 일 1,000회
# - 서비스: KorService2 (최신 버전)
TOURAPI_KEY=your-tour-api-key
TOURAPI_ENDPOINT=https://apis.data.go.kr/B551011/KorService2
# ============================================
# 📍 지오코딩 API (Geocoding)
# ============================================
# Kakao REST API
# - 용도: 주소 → 좌표 변환, 펜션 위치 기반 근처 축제 검색
# - 발급: https://developers.kakao.com/ (앱 생성 후 REST API 키)
# - 무료 할당량: 일 30,000회
KAKAO_REST_KEY=your-kakao-rest-api-key
# ============================================
# 📺 YouTube 업로드 (YouTube Upload)
# ============================================
# OAuth 2.0 방식 - 사용자별 개인 채널 연동
# YouTube OAuth 설정
# - 설정 파일: server/client_secret.json
# - 발급 방법:
# 1. Google Cloud Console (https://console.cloud.google.com)
# 2. "사용자 인증 정보" → "OAuth 2.0 클라이언트 ID" 생성
# 3. 리디렉션 URI 추가: http://localhost:3001/api/youtube/oauth/callback
# 4. JSON 다운로드 → server/client_secret.json으로 저장
# - 주의: 개발/테스트 앱은 "Google에서 확인하지 않은 앱" 경고가 표시됨
# ============================================
# 📊 Google Cloud Billing (선택)
# ============================================
# BigQuery를 통한 클라우드 비용 분석
# Google Cloud Billing 설정
# - 용도: API 사용량 비용 추적, 비용 분석 대시보드
# - 전제 조건:
# 1. Google Cloud 프로젝트에서 결제 내보내기 → BigQuery 설정
# 2. 서비스 계정 생성 (BigQuery 읽기 권한)
# 3. 서비스 계정 키 JSON 다운로드
# - KEY_PATH: 서비스 계정 키 파일 경로
# - PROJECT_ID: Google Cloud 프로젝트 ID
# - DATASET_ID: BigQuery 데이터셋 ID
GOOGLE_BILLING_KEY_PATH=./server/google-billing-key.json
GOOGLE_CLOUD_PROJECT_ID=your-project-id
GOOGLE_BILLING_DATASET_ID=billing_export
# ============================================
# 📧 이메일 서비스 (선택)
# ============================================
# Resend 이메일 API
# - 용도: 비밀번호 재설정, 알림 이메일 발송
# - 발급: https://resend.com/ (가입 후 API 키 발급)
# - 무료 할당량: 월 100통
# - 설정 안 하면 이메일 기능 비활성화 (앱은 정상 작동)
RESEND_API_KEY=your-resend-api-key
RESEND_FROM_EMAIL=CastAD <noreply@yourdomain.com>
# ============================================
# 🔐 소셜 로그인 (선택)
# ============================================
# Google OAuth 로그인
# - 용도: "Google로 로그인" 기능
# - 발급: Google Cloud Console → 사용자 인증 정보 → OAuth 2.0
# - 리디렉션 URI: http://localhost:3001/auth/google/callback
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
# Naver OAuth 로그인
# - 용도: "네이버로 로그인" 기능
# - 발급: https://developers.naver.com/ → 애플리케이션 등록
# - 리디렉션 URI: http://localhost:3001/auth/naver/callback
NAVER_CLIENT_ID=your-naver-client-id
NAVER_CLIENT_SECRET=your-naver-client-secret
# ============================================
# 📱 SNS 연동 (선택)
# ============================================
# Instagram 서비스 (Python 마이크로서비스)
# - 용도: Instagram 자동 업로드
# - 별도 설치 필요: server/instagram-service/
# - 의존성: Python 3.8+, Fernet 암호화
INSTAGRAM_SERVICE_URL=http://localhost:5001
INSTAGRAM_SERVICE_PORT=5001
INSTAGRAM_ENCRYPTION_KEY=your-fernet-encryption-key
# TikTok API
# - 용도: TikTok 영상 업로드
# - 발급: https://developers.tiktok.com/ → 앱 생성
# - 리디렉션 URI: http://localhost:3001/api/tiktok/oauth/callback
TIKTOK_CLIENT_KEY=your-tiktok-client-key
TIKTOK_CLIENT_SECRET=your-tiktok-client-secret
# ============================================
# 🔧 기타 설정 (선택)
# ============================================
# Naver 크롤링 쿠키 (선택)
# - 용도: 네이버 지도에서 펜션 정보/사진 크롤링
# - 획득 방법: 브라우저 개발자 도구 (F12) → Application → Cookies에서 복사
# - 주의: 쿠키 만료 시 재설정 필요 (또는 관리자 대시보드에서 설정)
# NAVER_COOKIES="NNB=xxx; JSESSIONID=xxx"
# Instagram 크롤링 쿠키 (선택)
# - 용도: 인스타그램 프로필에서 펜션 사진 크롤링
# - 획득 방법: 인스타그램 로그인 후 개발자 도구에서 쿠키 복사
# - 필수 쿠키: sessionid, csrftoken, ds_user_id
# - 주의: 쿠키 만료 시 재설정 필요 (또는 관리자 대시보드에서 설정)
# INSTAGRAM_COOKIES="sessionid=xxx; csrftoken=xxx; ds_user_id=xxx"
# ============================================
# 📝 설정 체크리스트
# ============================================
# 최소 필수 설정 (앱 실행에 필요):
# ✅ JWT_SECRET
# ✅ VITE_GEMINI_API_KEY
# ✅ SUNO_API_KEY
#
# 권장 설정 (주요 기능 활성화):
# ⬜ TOURAPI_KEY (축제 연동)
# ⬜ KAKAO_REST_KEY (위치 기반 축제 검색)
# ⬜ YouTube client_secret.json (YouTube 업로드)
#
# 선택 설정 (부가 기능):
# ⬜ RESEND_API_KEY (이메일 발송)
# ⬜ GOOGLE_CLIENT_ID/SECRET (Google 로그인)
# ⬜ NAVER_CLIENT_ID/SECRET (네이버 로그인)
# ⬜ TIKTOK_CLIENT_KEY/SECRET (TikTok 업로드)
# ⬜ INSTAGRAM_* (Instagram 업로드)
# ⬜ GOOGLE_BILLING_* (비용 분석)

173
.env.production.example Normal file
View File

@ -0,0 +1,173 @@
# ============================================
# CaStAD v3.2.0 프로덕션 환경 설정 파일
# ============================================
# 도메인: castad1.ktenterprise.net
# 서버 배포 시 이 파일을 .env로 복사하여 사용
# cp .env.production.example .env
# ============================================
# ============================================
# 🔧 기본 설정 (Basic Configuration)
# ============================================
# Node 환경 - 반드시 production으로 설정
NODE_ENV=production
# 백엔드 서버 포트
# - nginx가 이 포트로 프록시합니다
PORT=3001
# JWT 인증 시크릿 키
# - ⚠️ 반드시 복잡한 랜덤 문자열로 변경하세요!
# - 생성 명령: openssl rand -base64 32
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
# URL 설정 (프로덕션)
# - 실제 도메인으로 설정
# - HTTPS 필수
ALLOWED_ORIGINS=https://castad1.ktenterprise.net
FRONTEND_URL=https://castad1.ktenterprise.net
BACKEND_URL=https://castad1.ktenterprise.net
# ============================================
# 🔑 필수 API 키 (Required APIs)
# ============================================
# ⚠️ 이 키들이 없으면 핵심 기능이 작동하지 않습니다
# Google Gemini AI API
# - 용도: AI 텍스트 생성, 마케팅 문구 작성, AI 매직 라이트
# - 발급: https://ai.google.dev/ (Google AI Studio)
# - 무료 할당량: 분당 60회 요청, 일 1,500회
# - 유료 전환 시 사용량 기반 과금
VITE_GEMINI_API_KEY=your-gemini-api-key
# Suno AI API
# - 용도: AI 음악 생성 (배경음악, 광고 음악)
# - 발급: Suno API 프록시 서비스 사용
# - 참고: https://github.com/gcui-art/suno-api
SUNO_API_KEY=your-suno-api-key
# ============================================
# 🎪 축제 연동 API (Festival Integration)
# ============================================
# 한국관광공사 Tour API
# - 용도: 전국 축제/행사 정보 조회, 지역별 축제 연동
# - 발급: https://api.visitkorea.or.kr/ (회원가입 후 키 발급)
# - 무료 할당량: 일 1,000회
# - 서비스: KorService2 사용
TOURAPI_KEY=your-tour-api-key
TOURAPI_ENDPOINT=https://apis.data.go.kr/B551011/KorService2
# ============================================
# 📍 지오코딩 API (Geocoding)
# ============================================
# Kakao REST API
# - 용도: 주소 → 좌표 변환, 펜션 위치 기반 근처 축제 검색
# - 발급: https://developers.kakao.com/
# - 무료 할당량: 일 30,000회
KAKAO_REST_KEY=your-kakao-rest-api-key
# ============================================
# 📺 YouTube 업로드 (YouTube Upload)
# ============================================
# OAuth 2.0 방식 - 사용자별 개인 채널 연동
# YouTube OAuth 설정
# - 설정 파일: server/client_secret.json (별도 생성 필요)
# - 설정 방법:
# 1. Google Cloud Console → 사용자 인증 정보
# 2. OAuth 2.0 클라이언트 ID 생성 (웹 애플리케이션)
# 3. 승인된 리디렉션 URI 추가:
# https://castad1.ktenterprise.net/api/youtube/oauth/callback
# 4. JSON 다운로드 → server/client_secret.json으로 저장
# - 앱 게시: 테스트 사용자 100명 이상이면 앱 검증 필요
# ============================================
# 📊 Google Cloud Billing (선택)
# ============================================
# BigQuery를 통한 API 비용 분석 - 관리자 대시보드 기능
# Google Cloud Billing 설정
# - 용도: Gemini/Suno 등 API 사용 비용 추적
# - 설정 파일: server/google-billing-key.json (서비스 계정 키)
# - 설정 방법:
# 1. Google Cloud Console → 결제 → 결제 내보내기 → BigQuery
# 2. IAM → 서비스 계정 생성 (BigQuery 데이터 뷰어 역할)
# 3. 키 생성 → JSON 다운로드 → server/google-billing-key.json
GOOGLE_BILLING_KEY_PATH=./server/google-billing-key.json
GOOGLE_CLOUD_PROJECT_ID=your-project-id
GOOGLE_BILLING_DATASET_ID=billing_export
# ============================================
# 📧 이메일 서비스 (선택)
# ============================================
# Resend 이메일 API
# - 용도: 비밀번호 재설정, 알림 이메일
# - 발급: https://resend.com/
# - 설정 안 하면 이메일 기능 비활성화 (앱은 정상 작동)
# - 프로덕션: 도메인 인증 필요
RESEND_API_KEY=your-resend-api-key
RESEND_FROM_EMAIL=CastAD <noreply@castad1.ktenterprise.net>
# ============================================
# 🔐 소셜 로그인 (선택)
# ============================================
# Google OAuth 로그인
# - 리디렉션 URI: https://castad1.ktenterprise.net/auth/google/callback
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
# Naver OAuth 로그인
# - 리디렉션 URI: https://castad1.ktenterprise.net/auth/naver/callback
NAVER_CLIENT_ID=your-naver-client-id
NAVER_CLIENT_SECRET=your-naver-client-secret
# ============================================
# 📱 SNS 연동 (선택)
# ============================================
# Instagram 서비스 (Python 마이크로서비스)
# - 별도 설치 필요: server/instagram-service/
# - PM2로 별도 프로세스로 실행
INSTAGRAM_SERVICE_URL=http://localhost:5001
INSTAGRAM_SERVICE_PORT=5001
INSTAGRAM_ENCRYPTION_KEY=your-fernet-encryption-key
# TikTok API
# - 리디렉션 URI: https://castad1.ktenterprise.net/api/tiktok/oauth/callback
TIKTOK_CLIENT_KEY=your-tiktok-client-key
TIKTOK_CLIENT_SECRET=your-tiktok-client-secret
# ============================================
# 📋 프로덕션 배포 체크리스트
# ============================================
#
# 🔴 필수 (앱 실행에 반드시 필요):
# ✅ NODE_ENV=production
# ✅ JWT_SECRET (랜덤 문자열로 변경)
# ✅ VITE_GEMINI_API_KEY
# ✅ SUNO_API_KEY
# ✅ FRONTEND_URL, BACKEND_URL (실제 도메인)
#
# 🟡 권장 (주요 기능):
# ⬜ TOURAPI_KEY (축제 연동)
# ⬜ KAKAO_REST_KEY (위치 기반 축제 검색)
# ⬜ server/client_secret.json (YouTube 업로드)
#
# 🟢 선택 (부가 기능):
# ⬜ RESEND_API_KEY (이메일)
# ⬜ GOOGLE_CLIENT_ID/SECRET (Google 로그인)
# ⬜ NAVER_CLIENT_ID/SECRET (네이버 로그인)
# ⬜ TIKTOK_CLIENT_KEY/SECRET (TikTok)
# ⬜ INSTAGRAM_* (Instagram)
# ⬜ GOOGLE_BILLING_* (비용 분석)
#
# 🔒 보안 파일 (git에 포함하지 않음):
# ⬜ .env (이 파일)
# ⬜ server/client_secret.json (YouTube OAuth)
# ⬜ server/google-billing-key.json (BigQuery)
# ⬜ server/youtube-tokens.json (사용자 토큰, 자동생성)

13
.gitignore vendored
View File

@ -31,6 +31,19 @@ server/database.sqlite
.env .env
.env.* .env.*
!.env.example !.env.example
!.env.production.example
# Sensitive files (API keys, credentials)
server/google-billing-key.json
server/client_secret.json
server/youtube-tokens.json
**/tokens.json
# Analysis files
*_analysis_*.txt
# Windows Zone.Identifier files
*:Zone.Identifier
# Python # Python
__pycache__/ __pycache__/

1635
README.md

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { GeneratedAssets } from '../types'; import { GeneratedAssets } from '../types';
import { Play, Pause, RefreshCw, Download, Image as ImageIcon, Video as VideoIcon, Music, Mic, Loader2, Film, Share2, Youtube, ArrowLeft, Volume2, VolumeX } from 'lucide-react'; import { Play, Pause, RefreshCw, Download, Image as ImageIcon, Video as VideoIcon, Music, Mic, Loader2, Film, Share2, Youtube, Instagram, ArrowLeft, Volume2, VolumeX } from 'lucide-react';
import { mergeVideoAndAudio } from '../services/ffmpegService'; import { mergeVideoAndAudio } from '../services/ffmpegService';
import ShareModal from './ShareModal'; import ShareModal from './ShareModal';
import SlideshowBackground from './SlideshowBackground'; import SlideshowBackground from './SlideshowBackground';
@ -55,6 +55,16 @@ const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay =
const [showShareModal, setShowShareModal] = useState(false); const [showShareModal, setShowShareModal] = useState(false);
const [showSEOPreview, setShowSEOPreview] = useState(false); const [showSEOPreview, setShowSEOPreview] = useState(false);
// Instagram 업로드 상태
const [isUploadingInstagram, setIsUploadingInstagram] = useState(false);
const [instagramUploadStatus, setInstagramUploadStatus] = useState('');
const [instagramUrl, setInstagramUrl] = useState<string | null>(null);
// 렌더링 큐 상태
const [currentJobId, setCurrentJobId] = useState<string | null>(null);
const [renderStatus, setRenderStatus] = useState<'idle' | 'submitting' | 'processing' | 'completed' | 'failed'>('idle');
const [renderMessage, setRenderMessage] = useState('');
// AutoPlay 로직 // AutoPlay 로직
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
@ -199,49 +209,118 @@ const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay =
return () => clearInterval(interval); return () => clearInterval(interval);
}, [isPlaying, assets.adCopy.length]); }, [isPlaying, assets.adCopy.length]);
// URL을 Base64로 변환하는 유틸리티 함수
const urlToBase64 = async (url: string): Promise<string | undefined> => {
if (!url) return undefined;
try {
let response: Response;
if (url.startsWith('blob:')) {
response = await fetch(url);
} else if (url.startsWith('http')) {
response = await fetch(`/api/proxy/audio?url=${encodeURIComponent(url)}`);
} else {
return undefined;
}
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
resolve(result.split(',')[1]);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch (e) {
console.error('URL to Base64 failed:', e);
return undefined;
}
};
// 렌더링 작업 상태 폴링
const pollJobStatus = async (jobId: string) => {
const token = localStorage.getItem('token');
let pollCount = 0;
const maxPolls = 180; // 최대 3분 (1초 간격)
const poll = async () => {
if (pollCount >= maxPolls) {
setRenderStatus('failed');
setRenderMessage('렌더링 시간 초과');
setIsServerDownloading(false);
setIsRendering(false);
return;
}
try {
const res = await fetch(`/api/render/status/${jobId}`, {
headers: { 'Authorization': token ? `Bearer ${token}` : '' }
});
const data = await res.json();
if (!data.success) {
throw new Error(data.error);
}
const { job } = data;
setDownloadProgress(job.progress || 0);
if (job.status === 'completed') {
setRenderStatus('completed');
setRenderMessage('렌더링 완료!');
setLastProjectFolder(job.downloadUrl?.split('/')[2] || null);
setIsServerDownloading(false);
setIsRendering(false);
// 자동 다운로드
if (job.downloadUrl) {
const a = document.createElement('a');
a.href = job.downloadUrl;
a.download = `CastAD_${assets.businessName}_Final.mp4`;
document.body.appendChild(a);
a.click();
a.remove();
}
return;
}
if (job.status === 'failed') {
setRenderStatus('failed');
setRenderMessage(job.error_message || '렌더링 실패');
setIsServerDownloading(false);
setIsRendering(false);
alert(`렌더링 실패: ${job.error_message || '알 수 없는 오류'}\n크레딧이 환불되었습니다.`);
return;
}
// 계속 폴링
pollCount++;
setTimeout(poll, 1000);
} catch (error: any) {
console.error('폴링 오류:', error);
pollCount++;
setTimeout(poll, 2000); // 오류 시 2초 후 재시도
}
};
poll();
};
const handleServerDownload = async () => { const handleServerDownload = async () => {
if (isServerDownloading) return; if (isServerDownloading) return;
setIsServerDownloading(true); setIsServerDownloading(true);
setDownloadProgress(10); setRenderStatus('submitting');
setRenderMessage('렌더링 요청 중...');
const controller = new AbortController(); setDownloadProgress(5);
const timeoutId = setTimeout(() => controller.abort(), 300000);
try { try {
const urlToBase64 = async (url: string): Promise<string | undefined> => { // 데이터 준비
if (!url) return undefined; setDownloadProgress(10);
try {
// blob URL이든 외부 URL이든 모두 처리
let response: Response;
if (url.startsWith('blob:')) {
response = await fetch(url);
} else if (url.startsWith('http')) {
// 외부 URL은 프록시를 통해 가져옴 (CORS 우회)
response = await fetch(`/api/proxy/audio?url=${encodeURIComponent(url)}`);
} else {
return undefined;
}
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
resolve(result.split(',')[1]);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch (e) {
console.error('Audio URL to Base64 failed:', e);
return undefined;
}
};
setDownloadProgress(20);
const posterBase64 = await urlToBase64(assets.posterUrl); const posterBase64 = await urlToBase64(assets.posterUrl);
const audioBase64 = await urlToBase64(assets.audioUrl); const audioBase64 = await urlToBase64(assets.audioUrl);
setDownloadProgress(30); setDownloadProgress(15);
let imagesBase64: string[] = []; let imagesBase64: string[] = [];
if (assets.images && assets.images.length > 0) { if (assets.images && assets.images.length > 0) {
imagesBase64 = await Promise.all( imagesBase64 = await Promise.all(
@ -257,64 +336,72 @@ const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay =
); );
} }
setDownloadProgress(40); setDownloadProgress(20);
const payload = { const payload = {
...assets,
historyId: assets.id,
posterBase64, posterBase64,
audioBase64, audioBase64,
imagesBase64 imagesBase64,
adCopy: assets.adCopy,
textEffect: assets.textEffect || 'effect-fade',
businessName: assets.businessName,
aspectRatio: assets.aspectRatio || '9:16',
pensionId: assets.pensionId
}; };
setIsRendering(true);
setDownloadProgress(50);
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const response = await fetch('/render', {
// 렌더링 작업 시작 요청
const response = await fetch('/api/render/start', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '' 'Authorization': token ? `Bearer ${token}` : ''
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload)
signal: controller.signal
}); });
setDownloadProgress(80); const data = await response.json();
if (!response.ok) { if (!response.ok || !data.success) {
const err = await response.text(); // 이미 진행 중인 작업이 있는 경우
throw new Error(`서버 오류: ${err}`); if (data.errorCode === 'RENDER_IN_PROGRESS') {
setCurrentJobId(data.existingJobId);
setRenderStatus('processing');
setRenderMessage(`기존 작업 진행 중 (${data.existingJobProgress || 0}%)`);
setIsRendering(true);
setDownloadProgress(data.existingJobProgress || 0);
// 기존 작업 상태 폴링 시작
pollJobStatus(data.existingJobId);
return;
}
throw new Error(data.error || '렌더링 요청 실패');
} }
const folderNameHeader = response.headers.get('X-Project-Folder'); // 작업 ID 저장 및 폴링 시작
if (folderNameHeader) { setCurrentJobId(data.jobId);
setLastProjectFolder(decodeURIComponent(folderNameHeader)); setRenderStatus('processing');
} setRenderMessage(`렌더링 중... (크레딧 ${data.creditsCharged} 차감)`);
setIsRendering(true);
setDownloadProgress(25);
setDownloadProgress(90); // 상태 폴링 시작
const blob = await response.blob(); pollJobStatus(data.jobId);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `CastAD_${assets.businessName}_Final.mp4`;
document.body.appendChild(a);
a.click();
a.remove();
setDownloadProgress(100);
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
if (e.name === 'AbortError') { setRenderStatus('failed');
alert("영상 생성 시간 초과 (5분). 서버 부하가 높거나 네트워크 문제일 수 있습니다."); setRenderMessage(e.message || '렌더링 요청 실패');
} else {
alert("영상 생성 실패: 서버가 실행 중인지 확인해주세요.");
}
} finally {
clearTimeout(timeoutId);
setIsServerDownloading(false); setIsServerDownloading(false);
setIsRendering(false); setIsRendering(false);
setTimeout(() => setDownloadProgress(0), 2000); setDownloadProgress(0);
if (e.message?.includes('크레딧')) {
alert(e.message);
} else {
alert(`렌더링 요청 실패: ${e.message}`);
}
} }
}; };
@ -356,49 +443,35 @@ const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay =
// 비디오 경로 생성 // 비디오 경로 생성
const videoPath = `downloads/${lastProjectFolder}/final.mp4`; const videoPath = `downloads/${lastProjectFolder}/final.mp4`;
let response; // YouTube 연결 확인
if (!connData.connected) {
if (connData.connected) { throw new Error("YouTube 계정이 연결되지 않았습니다. 설정에서 YouTube 계정을 먼저 연결해주세요.");
// 사용자 채널에 업로드 (새 API)
setUploadStatus("내 채널에 업로드 중...");
response = await fetch('/api/youtube/my-upload', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify({
videoPath,
seoData: { title, description, tags, pinnedComment },
historyId: assets.id,
categoryId
})
});
} else {
// 레거시 업로드 (시스템 채널)
setUploadStatus("시스템 채널에 업로드 중...");
response = await fetch('/api/youtube/upload', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify({
videoPath,
seoData: { title, description, tags },
categoryId
})
});
} }
// 사용자 채널에 업로드
setUploadStatus("내 채널에 업로드 중...");
const response = await fetch('/api/youtube/my-upload', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify({
videoPath,
seoData: { title, description, tags, pinnedComment },
historyId: assets.id,
categoryId
})
});
if (!response.ok) { if (!response.ok) {
const errData = await response.json(); const errData = await response.json();
throw new Error(errData.error || "업로드 실패"); throw new Error(errData.error || "업로드 실패");
} }
const data = await response.json(); const data = await response.json();
setYoutubeUrl(data.youtubeUrl); setYoutubeUrl(data.youtubeUrl || data.url);
setUploadStatus(connData.connected ? "내 채널에 업로드 완료!" : "업로드 완료!"); setUploadStatus("내 채널에 업로드 완료!");
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
@ -409,6 +482,67 @@ const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay =
} }
}; };
// Instagram 업로드 핸들러
const handleInstagramUpload = async () => {
if (!lastProjectFolder) {
alert("먼저 영상을 생성(다운로드)해야 업로드할 수 있습니다.");
return;
}
setIsUploadingInstagram(true);
setInstagramUploadStatus("Instagram 연결 확인 중...");
setInstagramUrl(null);
const token = localStorage.getItem('token');
try {
// Instagram 연결 상태 확인
const statusRes = await fetch('/api/instagram/status', {
headers: { 'Authorization': token ? `Bearer ${token}` : '' }
});
const statusData = await statusRes.json();
if (!statusData.connected) {
throw new Error("Instagram 계정이 연결되지 않았습니다. 설정에서 Instagram 계정을 먼저 연결해주세요.");
}
// 캡션 생성 (광고 카피)
const adCopyText = assets.adCopy?.join('\n') || assets.businessName || '';
const hashtags = `#${assets.businessName?.replace(/\s+/g, '')} #펜션 #숙소 #여행 #힐링 #휴가 #AI마케팅 #CaStAD`;
setInstagramUploadStatus("Instagram Reels 업로드 중...");
const response = await fetch('/api/instagram/upload', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify({
history_id: assets.id,
caption: adCopyText,
hashtags: hashtags
})
});
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.error || "Instagram 업로드 실패");
}
const data = await response.json();
setInstagramUrl(data.mediaUrl || data.url || 'uploaded');
setInstagramUploadStatus("Instagram Reels 업로드 완료!");
} catch (e: any) {
console.error(e);
setInstagramUploadStatus(e.message || "업로드 실패");
alert(`Instagram 업로드 실패: ${e.message}`);
} finally {
setIsUploadingInstagram(false);
}
};
const handleMergeDownload = async () => { const handleMergeDownload = async () => {
if (isMerging) return; if (isMerging) return;
@ -896,16 +1030,75 @@ const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay =
</Badge> </Badge>
</div> </div>
)} )}
{/* Instagram 업로드 */}
{!instagramUrl ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
onClick={handleInstagramUpload}
disabled={isUploadingInstagram || !lastProjectFolder}
className={cn(
"gap-2",
lastProjectFolder && "bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 hover:from-purple-600 hover:via-pink-600 hover:to-orange-600 text-white border-0"
)}
>
{isUploadingInstagram ? <Loader2 className="w-4 h-4 animate-spin" /> : <Instagram className="w-4 h-4" />}
Instagram
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{!lastProjectFolder
? (t('textStyle') === '자막 스타일' ? '먼저 영상을 저장하세요' : 'Save video first')
: (t('textStyle') === '자막 스타일' ? 'Instagram Reels 업로드' : 'Upload to Instagram Reels')
}</p>
</TooltipContent>
</Tooltip>
) : (
<div className="flex items-center gap-2">
<Button
variant="default"
className="bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 gap-2"
>
<Instagram className="w-4 h-4" />
{t('textStyle') === '자막 스타일' ? '완료' : 'Done'}
</Button>
<Badge variant="outline" className="text-green-500 border-green-500/50">
{t('textStyle') === '자막 스타일' ? '업로드 완료' : 'Uploaded'}
</Badge>
</div>
)}
</TooltipProvider> </TooltipProvider>
</div> </div>
{/* 다운로드 진행률 */} {/* 렌더링 진행률 및 상태 */}
{downloadProgress > 0 && downloadProgress < 100 && ( {(downloadProgress > 0 || renderStatus === 'processing') && (
<div className="w-full pt-2 border-t border-border/50"> <div className="w-full pt-2 border-t border-border/50">
<Progress value={downloadProgress} className="h-2" /> <Progress value={downloadProgress} className="h-2" />
<p className="text-xs text-muted-foreground text-center mt-1"> <div className="flex items-center justify-center gap-2 mt-1">
{t('textStyle') === '자막 스타일' ? '렌더링 중...' : 'Rendering...'} {downloadProgress}% {renderStatus === 'processing' && <Loader2 className="w-3 h-3 animate-spin" />}
</p> <p className="text-xs text-muted-foreground">
{renderMessage || (t('textStyle') === '자막 스타일' ? '렌더링 중...' : 'Rendering...')} {downloadProgress}%
</p>
</div>
{renderStatus === 'processing' && (
<p className="text-xs text-muted-foreground/70 text-center mt-1">
{t('textStyle') === '자막 스타일'
? '페이지를 나가도 렌더링은 계속됩니다'
: 'Rendering continues even if you leave'}
</p>
)}
</div>
)}
{/* Instagram 업로드 상태 */}
{isUploadingInstagram && instagramUploadStatus && (
<div className="w-full pt-2 border-t border-border/50">
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-pink-500" />
<p className="text-xs text-muted-foreground">{instagramUploadStatus}</p>
</div>
</div> </div>
)} )}
</CardContent> </CardContent>

295
deploy.sh Normal file → Executable file
View File

@ -1,156 +1,175 @@
#!/bin/bash #!/bin/bash
#
# CaStAD 배포 스크립트
# 로컬에서 castad1.ktenterprise.net으로 배포
#
# 색상 정의 # ═══════════════════════════════════════════════════════════════
# 설정
# ═══════════════════════════════════════════════════════════════
SERVER="castad1.ktenterprise.net"
SERVER_USER="castad" # 서버 사용자
REMOTE_PATH="/home/${SERVER_USER}/castad" # 서버 경로
LOCAL_PATH="$(cd "$(dirname "$0")" && pwd)"
# 색상
GREEN='\033[0;32m' GREEN='\033[0;32m'
BLUE='\033[0;34m' CYAN='\033[0;36m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
RED='\033[0;31m' RED='\033[0;31m'
NC='\033[0m' # No Color BOLD='\033[1m'
NC='\033[0m'
echo -e "${BLUE}=== BizVibe 배포 자동화 스크립트 ===${NC}" log() { echo -e "${GREEN}[Deploy]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; }
info() { echo -e "${CYAN}[INFO]${NC} $1"; }
# 1. 현재 경로 확인 # ═══════════════════════════════════════════════════════════════
PROJECT_ROOT=$(pwd) # 빌드
DIST_PATH="$PROJECT_ROOT/dist" # ═══════════════════════════════════════════════════════════════
echo -e "${GREEN}[1] 현재 프로젝트 경로:${NC} $PROJECT_ROOT" do_build() {
log "프론트엔드 빌드 중..."
cd "$LOCAL_PATH"
npm run build
if [ $? -ne 0 ]; then
error "빌드 실패!"
exit 1
fi
info "빌드 완료"
}
# 2. 시스템 의존성 설치 (Puppeteer/Chromium 용) # ═══════════════════════════════════════════════════════════════
echo -e "${GREEN}[1.5] Installing Puppeteer system dependencies...${NC}" # 서버로 파일 전송
if [ -x "$(command -v apt-get)" ]; then # ═══════════════════════════════════════════════════════════════
sudo apt-get update && sudo apt-get install -y \ do_upload() {
ca-certificates \ log "서버로 파일 전송 중..."
fonts-liberation \
libasound2t64 \ # 제외할 파일/폴더
libatk-bridge2.0-0 \ EXCLUDES="--exclude=node_modules --exclude=.git --exclude=*.log --exclude=pids --exclude=logs --exclude=server/database.sqlite --exclude=server/downloads --exclude=server/temp --exclude=.env"
libatk1.0-0 \
libc6 \ # rsync로 동기화
libcairo2 \ rsync -avz --progress $EXCLUDES \
libcups2 \ "$LOCAL_PATH/" \
libdbus-1-3 \ "${SERVER_USER}@${SERVER}:${REMOTE_PATH}/"
libexpat1 \
libfontconfig1 \
libgbm1 \
libgcc1 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libstdc++6 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxcursor1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxi6 \
libxrandr2 \
libxrender1 \
libxss1 \
libxtst6 \
lsb-release \
wget \
xdg-utils \
fonts-noto-cjk
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${RED}❌ System dependency installation failed!${NC}" error "파일 전송 실패!"
# 의존성 설치 실패해도 일단 진행 (이미 설치된 경우 등 고려) exit 1
else
echo -e "${GREEN}✅ System dependencies installed successfully.${NC}"
fi fi
else info "파일 전송 완료"
echo -e "${YELLOW}⚠️ 'apt-get' not found. Skipping system dependency installation. Ensure dependencies are installed manually.${NC}"
fi
# 3. 프로젝트 빌드
echo -e "${GREEN}[2] Building project...${NC}"
npm run build:all
if [ $? -ne 0 ]; then
echo -e "${RED}❌ 빌드 실패! 오류를 확인해주세요.${NC}"
exit 1
fi
echo -e "${GREEN}✅ 빌드 완료!${NC}"
# 3. Nginx 설정 파일 생성 (경로 자동 적용)
echo -e "${GREEN}[3] Nginx 설정 파일 생성 중...${NC}"
NGINX_CONF="nginx_bizvibe.conf"
DOMAIN_NAME="bizvibe.ktenterprise.net" # 기본 도메인 (필요 시 수정)
cat > $NGINX_CONF <<EOF
server {
listen 80;
server_name $DOMAIN_NAME;
# 1. 프론트엔드 (React 빌드 결과물)
location / {
root $DIST_PATH;
index index.html;
try_files \$uri \$uri/ /index.html;
}
# 2. 백엔드 API 프록시
location /api {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_cache_bypass \$http_upgrade;
}
# 3. 영상 생성 및 다운로드 프록시
location /render {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host \$host;
}
location /downloads {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host \$host;
}
location /temp {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host \$host;
}
} }
EOF
echo -e "${GREEN}✅ Nginx 설정 파일($NGINX_CONF) 생성 완료! (Root 경로: $DIST_PATH)${NC}" # ═══════════════════════════════════════════════════════════════
# 서버에서 설치 및 재시작
# ═══════════════════════════════════════════════════════════════
do_install() {
log "서버에서 의존성 설치 중..."
# 4. PM2로 백엔드 시작 ssh "${SERVER_USER}@${SERVER}" << 'REMOTE_SCRIPT'
echo -e "${GREEN}[4] PM2로 백엔드 서버 시작...${NC}" cd /home/castad/castad
# PM2 설치 확인 및 설치 # npm 의존성 설치
if ! command -v pm2 &> /dev/null echo "=== 프론트엔드 의존성 설치 ==="
then npm install --legacy-peer-deps --silent
echo -e "${YELLOW}PM2가 설치되어 있지 않습니다. 설치를 시도합니다...${NC}"
npm install -g pm2
fi
# 기존 프로세스 재시작 또는 새로 시작 echo "=== 백엔드 의존성 설치 ==="
pm2 restart bizvibe-backend 2>/dev/null || pm2 start server/index.js --name "bizvibe-backend" cd server && npm install --legacy-peer-deps --silent && cd ..
pm2 save echo "=== 완료 ==="
echo -e "${GREEN}✅ 백엔드 서버 구동 완료!${NC}" REMOTE_SCRIPT
# 5. 최종 안내 (sudo 필요 작업) if [ $? -ne 0 ]; then
echo -e "" error "서버 설치 실패!"
echo -e "${BLUE}=== 🎉 배포 준비 완료! 남은 단계 ===${NC}" exit 1
echo -e "${YELLOW}다음 명령어를 복사하여 실행하면 Nginx 설정이 적용됩니다 (관리자 권한 필요):${NC}" fi
echo -e "" info "서버 설치 완료"
echo -e "1. 설정 파일 이동:" }
echo -e " ${GREEN}sudo cp $NGINX_CONF /etc/nginx/sites-available/bizvibe${NC}"
echo -e "" do_restart() {
echo -e "2. 사이트 활성화:" log "서버 재시작 중..."
echo -e " ${GREEN}sudo ln -s /etc/nginx/sites-available/bizvibe /etc/nginx/sites-enabled/${NC}"
echo -e "" ssh "${SERVER_USER}@${SERVER}" << 'REMOTE_SCRIPT'
echo -e "3. Nginx 문법 검사 및 재시작:" cd /home/castad/castad
echo -e " ${GREEN}sudo nginx -t && sudo systemctl reload nginx${NC}" ./startserver.sh restart
echo -e "" REMOTE_SCRIPT
info "서버 재시작 완료"
}
# ═══════════════════════════════════════════════════════════════
# 전체 배포
# ═══════════════════════════════════════════════════════════════
do_full_deploy() {
echo ""
echo -e "${BOLD}${CYAN}╔════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${CYAN}║ CaStAD 배포 시작 ║${NC}"
echo -e "${BOLD}${CYAN}║ Target: ${SERVER}${NC}"
echo -e "${BOLD}${CYAN}╚════════════════════════════════════════════════════════╝${NC}"
echo ""
do_build
do_upload
do_install
do_restart
echo ""
echo -e "${GREEN}╔════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ 배포 완료! ║${NC}"
echo -e "${GREEN}║ URL: https://${SERVER}${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════════╝${NC}"
echo ""
}
# ═══════════════════════════════════════════════════════════════
# 도움말
# ═══════════════════════════════════════════════════════════════
show_help() {
echo ""
echo -e "${BOLD}CaStAD 배포 스크립트${NC}"
echo ""
echo -e "${CYAN}사용법:${NC}"
echo " ./deploy.sh <command>"
echo ""
echo -e "${CYAN}명령어:${NC}"
echo " deploy - 전체 배포 (빌드 → 업로드 → 설치 → 재시작)"
echo " build - 로컬 빌드만"
echo " upload - 파일 업로드만"
echo " install - 서버 의존성 설치"
echo " restart - 서버 재시작만"
echo ""
echo -e "${CYAN}설정:${NC}"
echo " 서버: ${SERVER}"
echo " 사용자: ${SERVER_USER}"
echo " 경로: ${REMOTE_PATH}"
echo ""
echo -e "${YELLOW}사전 준비:${NC}"
echo " 1. SSH 키 설정: ssh-copy-id ${SERVER_USER}@${SERVER}"
echo " 2. 서버에 .env 파일 생성"
echo " 3. Nginx 설정 (nginx.castad.conf 참고)"
echo " 4. SSL 인증서 설정 (Let's Encrypt)"
echo ""
}
# ═══════════════════════════════════════════════════════════════
# 메인
# ═══════════════════════════════════════════════════════════════
case "$1" in
deploy)
do_full_deploy
;;
build)
do_build
;;
upload)
do_upload
;;
install)
do_install
;;
restart)
do_restart
;;
*)
show_help
;;
esac

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,168 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 800" font-family="Arial, sans-serif">
<defs>
<linearGradient id="lbGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
<linearGradient id="apiGradD" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#11998e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#38ef7d;stop-opacity:1" />
</linearGradient>
<linearGradient id="redisGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#dc2626;stop-opacity:1" />
<stop offset="100%" style="stop-color:#ef4444;stop-opacity:1" />
</linearGradient>
<linearGradient id="pgGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#336791;stop-opacity:1" />
<stop offset="100%" style="stop-color:#4a90c2;stop-opacity:1" />
</linearGradient>
<linearGradient id="s3Grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#ff9900;stop-opacity:1" />
<stop offset="100%" style="stop-color:#ffb84d;stop-opacity:1" />
</linearGradient>
<linearGradient id="workerGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#a78bfa;stop-opacity:1" />
</linearGradient>
<linearGradient id="uploadGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#ec4899;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f472b6;stop-opacity:1" />
</linearGradient>
<filter id="shadowD" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.3"/>
</filter>
<marker id="arrowD" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#666"/>
</marker>
</defs>
<!-- Title -->
<text x="450" y="30" text-anchor="middle" font-size="20" font-weight="bold" fill="#333">
CaStAD Distributed Architecture (Scale-Out)
</text>
<!-- Load Balancer -->
<rect x="325" y="50" width="250" height="60" rx="10" fill="url(#lbGrad)" filter="url(#shadowD)"/>
<text x="450" y="78" text-anchor="middle" font-size="14" font-weight="bold" fill="white">Load Balancer</text>
<text x="450" y="98" text-anchor="middle" font-size="11" fill="white">nginx / AWS ALB</text>
<!-- Arrows to API Servers -->
<path d="M 350 110 L 175 145" stroke="#666" stroke-width="2" marker-end="url(#arrowD)"/>
<path d="M 450 110 L 450 145" stroke="#666" stroke-width="2" marker-end="url(#arrowD)"/>
<path d="M 550 110 L 725 145" stroke="#666" stroke-width="2" marker-end="url(#arrowD)"/>
<!-- API Servers -->
<rect x="75" y="145" width="200" height="70" rx="10" fill="url(#apiGradD)" filter="url(#shadowD)"/>
<text x="175" y="175" text-anchor="middle" font-size="13" font-weight="bold" fill="white">API Server 1</text>
<text x="175" y="195" text-anchor="middle" font-size="11" fill="white">Express.js</text>
<rect x="350" y="145" width="200" height="70" rx="10" fill="url(#apiGradD)" filter="url(#shadowD)"/>
<text x="450" y="175" text-anchor="middle" font-size="13" font-weight="bold" fill="white">API Server 2</text>
<text x="450" y="195" text-anchor="middle" font-size="11" fill="white">Express.js</text>
<rect x="625" y="145" width="200" height="70" rx="10" fill="url(#apiGradD)" filter="url(#shadowD)"/>
<text x="725" y="175" text-anchor="middle" font-size="13" font-weight="bold" fill="white">API Server N</text>
<text x="725" y="195" text-anchor="middle" font-size="11" fill="white">Express.js</text>
<!-- Vertical line connecting API servers -->
<path d="M 175 215 L 175 250 L 725 250 L 725 215" stroke="#666" stroke-width="1.5" fill="none"/>
<path d="M 450 215 L 450 250" stroke="#666" stroke-width="1.5"/>
<path d="M 450 250 L 450 285" stroke="#666" stroke-width="2" marker-end="url(#arrowD)"/>
<!-- Data Layer -->
<!-- Redis -->
<rect x="50" y="300" width="220" height="120" rx="10" fill="url(#redisGrad)" filter="url(#shadowD)"/>
<text x="160" y="330" text-anchor="middle" font-size="14" font-weight="bold" fill="white">Redis</text>
<text x="160" y="350" text-anchor="middle" font-size="11" fill="white">(Cache / Queue)</text>
<text x="160" y="375" text-anchor="middle" font-size="10" fill="rgba(255,255,255,0.9)">• Session Store</text>
<text x="160" y="390" text-anchor="middle" font-size="10" fill="rgba(255,255,255,0.9)">• Job Queue (Bull)</text>
<text x="160" y="405" text-anchor="middle" font-size="10" fill="rgba(255,255,255,0.9)">• Rate Limiting</text>
<!-- PostgreSQL -->
<rect x="340" y="300" width="220" height="120" rx="10" fill="url(#pgGrad)" filter="url(#shadowD)"/>
<text x="450" y="330" text-anchor="middle" font-size="14" font-weight="bold" fill="white">PostgreSQL</text>
<text x="450" y="350" text-anchor="middle" font-size="11" fill="white">(Primary)</text>
<rect x="370" y="365" width="160" height="40" rx="5" fill="rgba(255,255,255,0.2)"/>
<text x="450" y="385" text-anchor="middle" font-size="10" fill="white">Replica (Read)</text>
<text x="450" y="400" text-anchor="middle" font-size="10" fill="rgba(255,255,255,0.8)">High Availability</text>
<!-- S3/MinIO -->
<rect x="630" y="300" width="220" height="120" rx="10" fill="url(#s3Grad)" filter="url(#shadowD)"/>
<text x="740" y="330" text-anchor="middle" font-size="14" font-weight="bold" fill="white">S3 / MinIO</text>
<text x="740" y="350" text-anchor="middle" font-size="11" fill="white">(Storage)</text>
<text x="740" y="375" text-anchor="middle" font-size="10" fill="rgba(255,255,255,0.9)">• Videos</text>
<text x="740" y="390" text-anchor="middle" font-size="10" fill="rgba(255,255,255,0.9)">• Images</text>
<text x="740" y="405" text-anchor="middle" font-size="10" fill="rgba(255,255,255,0.9)">• Assets</text>
<!-- Connecting lines from data layer -->
<path d="M 160 420 L 160 450 L 450 450 L 450 480" stroke="#666" stroke-width="1.5" fill="none"/>
<path d="M 450 420 L 450 480" stroke="#666" stroke-width="1.5"/>
<path d="M 740 420 L 740 450 L 450 450" stroke="#666" stroke-width="1.5" fill="none"/>
<path d="M 450 480 L 450 495" stroke="#666" stroke-width="2" marker-end="url(#arrowD)"/>
<!-- Render Workers -->
<rect x="75" y="510" width="180" height="70" rx="10" fill="url(#workerGrad)" filter="url(#shadowD)"/>
<text x="165" y="540" text-anchor="middle" font-size="12" font-weight="bold" fill="white">Render Worker 1</text>
<text x="165" y="560" text-anchor="middle" font-size="10" fill="white">Puppeteer + FFmpeg</text>
<rect x="310" y="510" width="180" height="70" rx="10" fill="url(#workerGrad)" filter="url(#shadowD)"/>
<text x="400" y="540" text-anchor="middle" font-size="12" font-weight="bold" fill="white">Render Worker 2</text>
<text x="400" y="560" text-anchor="middle" font-size="10" fill="white">Puppeteer + FFmpeg</text>
<rect x="545" y="510" width="180" height="70" rx="10" fill="url(#workerGrad)" filter="url(#shadowD)"/>
<text x="635" y="540" text-anchor="middle" font-size="12" font-weight="bold" fill="white">Render Worker N</text>
<text x="635" y="560" text-anchor="middle" font-size="10" fill="white">Puppeteer + FFmpeg</text>
<!-- Arrows to Render Workers -->
<path d="M 350 495 L 165 510" stroke="#666" stroke-width="1.5"/>
<path d="M 450 495 L 400 510" stroke="#666" stroke-width="1.5"/>
<path d="M 550 495 L 635 510" stroke="#666" stroke-width="1.5"/>
<!-- Connecting line to Upload Workers -->
<path d="M 165 580 L 165 610 L 635 610 L 635 580" stroke="#666" stroke-width="1.5" fill="none"/>
<path d="M 400 580 L 400 610" stroke="#666" stroke-width="1.5"/>
<path d="M 400 610 L 400 640" stroke="#666" stroke-width="2" marker-end="url(#arrowD)"/>
<!-- Upload Workers -->
<rect x="75" y="655" width="180" height="70" rx="10" fill="url(#uploadGrad)" filter="url(#shadowD)"/>
<text x="165" y="685" text-anchor="middle" font-size="12" font-weight="bold" fill="white">Upload Worker 1</text>
<text x="165" y="705" text-anchor="middle" font-size="10" fill="white">YouTube</text>
<rect x="310" y="655" width="180" height="70" rx="10" fill="url(#uploadGrad)" filter="url(#shadowD)"/>
<text x="400" y="685" text-anchor="middle" font-size="12" font-weight="bold" fill="white">Upload Worker 2</text>
<text x="400" y="705" text-anchor="middle" font-size="10" fill="white">Instagram</text>
<rect x="545" y="655" width="180" height="70" rx="10" fill="url(#uploadGrad)" filter="url(#shadowD)"/>
<text x="635" y="685" text-anchor="middle" font-size="12" font-weight="bold" fill="white">Upload Worker N</text>
<text x="635" y="705" text-anchor="middle" font-size="10" fill="white">TikTok</text>
<!-- Arrows to Upload Workers -->
<path d="M 300 640 L 165 655" stroke="#666" stroke-width="1.5"/>
<path d="M 400 640 L 400 655" stroke="#666" stroke-width="1.5"/>
<path d="M 500 640 L 635 655" stroke="#666" stroke-width="1.5"/>
<!-- External APIs label -->
<rect x="750" y="655" width="130" height="70" rx="8" fill="#f0f0f0" stroke="#ccc" stroke-width="1"/>
<text x="815" y="680" text-anchor="middle" font-size="11" font-weight="bold" fill="#666">External APIs</text>
<text x="815" y="698" text-anchor="middle" font-size="9" fill="#888">Gemini • Suno</text>
<text x="815" y="712" text-anchor="middle" font-size="9" fill="#888">YouTube • IG • TikTok</text>
<!-- Capacity Labels -->
<rect x="780" y="50" width="100" height="25" rx="5" fill="#22c55e"/>
<text x="830" y="68" text-anchor="middle" font-size="11" fill="white">50,000+ users</text>
<!-- Legend -->
<rect x="20" y="750" width="860" height="40" rx="5" fill="#f8f9fa" stroke="#e9ecef"/>
<circle cx="60" cy="770" r="8" fill="url(#apiGradD)"/>
<text x="75" y="774" font-size="10" fill="#666">API Server</text>
<circle cx="180" cy="770" r="8" fill="url(#redisGrad)"/>
<text x="195" y="774" font-size="10" fill="#666">Redis</text>
<circle cx="280" cy="770" r="8" fill="url(#pgGrad)"/>
<text x="295" y="774" font-size="10" fill="#666">PostgreSQL</text>
<circle cx="400" cy="770" r="8" fill="url(#s3Grad)"/>
<text x="415" y="774" font-size="10" fill="#666">S3/MinIO</text>
<circle cx="510" cy="770" r="8" fill="url(#workerGrad)"/>
<text x="525" y="774" font-size="10" fill="#666">Render Worker</text>
<circle cx="650" cy="770" r="8" fill="url(#uploadGrad)"/>
<text x="665" y="774" font-size="10" fill="#666">Upload Worker</text>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,183 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 700" font-family="Arial, sans-serif">
<defs>
<linearGradient id="k8sGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#326ce5;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
</linearGradient>
<linearGradient id="ingressGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
<stop offset="100%" style="stop-color:#4f46e5;stop-opacity:1" />
</linearGradient>
<linearGradient id="deployGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#10b981;stop-opacity:1" />
<stop offset="100%" style="stop-color:#059669;stop-opacity:1" />
</linearGradient>
<linearGradient id="podGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#34d399;stop-opacity:1" />
<stop offset="100%" style="stop-color:#10b981;stop-opacity:1" />
</linearGradient>
<linearGradient id="statefulGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#f59e0b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
</linearGradient>
<linearGradient id="externalGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#94a3b8;stop-opacity:1" />
<stop offset="100%" style="stop-color:#64748b;stop-opacity:1" />
</linearGradient>
<filter id="shadowK" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.3"/>
</filter>
</defs>
<!-- Title -->
<text x="450" y="28" text-anchor="middle" font-size="20" font-weight="bold" fill="#333">
CaStAD Kubernetes Architecture
</text>
<!-- Main K8s Cluster Box -->
<rect x="30" y="45" width="840" height="590" rx="15" fill="url(#k8sGrad)" filter="url(#shadowK)" opacity="0.1"/>
<rect x="30" y="45" width="840" height="590" rx="15" fill="none" stroke="url(#k8sGrad)" stroke-width="3"/>
<!-- K8s Logo and Label -->
<text x="450" y="75" text-anchor="middle" font-size="16" font-weight="bold" fill="#326ce5">Kubernetes Cluster</text>
<!-- Ingress Controller -->
<rect x="250" y="95" width="400" height="55" rx="10" fill="url(#ingressGrad)" filter="url(#shadowK)"/>
<text x="450" y="120" text-anchor="middle" font-size="13" font-weight="bold" fill="white">Ingress Controller</text>
<text x="450" y="138" text-anchor="middle" font-size="10" fill="rgba(255,255,255,0.9)">nginx-ingress / Traefik</text>
<!-- Arrow from Ingress -->
<path d="M 450 150 L 450 175" stroke="#666" stroke-width="2"/>
<!-- Deployments Section -->
<rect x="50" y="175" width="800" height="280" rx="10" fill="rgba(16,185,129,0.1)" stroke="#10b981" stroke-width="1" stroke-dasharray="5,5"/>
<text x="450" y="195" text-anchor="middle" font-size="12" font-weight="bold" fill="#059669">Deployments (Auto-Scaling with HPA)</text>
<!-- API Deployment -->
<rect x="70" y="210" width="250" height="110" rx="8" fill="url(#deployGrad)" filter="url(#shadowK)"/>
<text x="195" y="232" text-anchor="middle" font-size="12" font-weight="bold" fill="white">API Deployment</text>
<text x="195" y="248" text-anchor="middle" font-size="10" fill="rgba(255,255,255,0.9)">HPA: 2-10 pods</text>
<!-- API Pods -->
<g transform="translate(85, 260)">
<rect x="0" y="0" width="40" height="45" rx="5" fill="url(#podGrad)"/>
<text x="20" y="28" text-anchor="middle" font-size="9" fill="white">Pod</text>
</g>
<g transform="translate(130, 260)">
<rect x="0" y="0" width="40" height="45" rx="5" fill="url(#podGrad)"/>
<text x="20" y="28" text-anchor="middle" font-size="9" fill="white">Pod</text>
</g>
<g transform="translate(175, 260)">
<rect x="0" y="0" width="40" height="45" rx="5" fill="url(#podGrad)"/>
<text x="20" y="28" text-anchor="middle" font-size="9" fill="white">Pod</text>
</g>
<g transform="translate(220, 260)">
<rect x="0" y="0" width="40" height="45" rx="5" fill="#d1d5db"/>
<text x="20" y="28" text-anchor="middle" font-size="9" fill="#666">...</text>
</g>
<!-- Render Worker Deployment -->
<rect x="340" y="210" width="250" height="110" rx="8" fill="url(#deployGrad)" filter="url(#shadowK)"/>
<text x="465" y="232" text-anchor="middle" font-size="12" font-weight="bold" fill="white">Render Worker</text>
<text x="465" y="248" text-anchor="middle" font-size="10" fill="rgba(255,255,255,0.9)">HPA: 3-20 pods (GPU nodes)</text>
<!-- Render Pods -->
<g transform="translate(355, 260)">
<rect x="0" y="0" width="40" height="45" rx="5" fill="url(#podGrad)"/>
<text x="20" y="28" text-anchor="middle" font-size="9" fill="white">Pod</text>
</g>
<g transform="translate(400, 260)">
<rect x="0" y="0" width="40" height="45" rx="5" fill="url(#podGrad)"/>
<text x="20" y="28" text-anchor="middle" font-size="9" fill="white">Pod</text>
</g>
<g transform="translate(445, 260)">
<rect x="0" y="0" width="40" height="45" rx="5" fill="url(#podGrad)"/>
<text x="20" y="28" text-anchor="middle" font-size="9" fill="white">Pod</text>
</g>
<g transform="translate(490, 260)">
<rect x="0" y="0" width="40" height="45" rx="5" fill="#d1d5db"/>
<text x="20" y="28" text-anchor="middle" font-size="9" fill="#666">...</text>
</g>
<!-- Upload Worker Deployment -->
<rect x="610" y="210" width="220" height="110" rx="8" fill="url(#deployGrad)" filter="url(#shadowK)"/>
<text x="720" y="232" text-anchor="middle" font-size="12" font-weight="bold" fill="white">Upload Worker</text>
<text x="720" y="248" text-anchor="middle" font-size="10" fill="rgba(255,255,255,0.9)">HPA: 2-5 pods</text>
<!-- Upload Pods -->
<g transform="translate(640, 260)">
<rect x="0" y="0" width="40" height="45" rx="5" fill="url(#podGrad)"/>
<text x="20" y="28" text-anchor="middle" font-size="9" fill="white">Pod</text>
</g>
<g transform="translate(685, 260)">
<rect x="0" y="0" width="40" height="45" rx="5" fill="url(#podGrad)"/>
<text x="20" y="28" text-anchor="middle" font-size="9" fill="white">Pod</text>
</g>
<g transform="translate(730, 260)">
<rect x="0" y="0" width="40" height="45" rx="5" fill="#d1d5db"/>
<text x="20" y="28" text-anchor="middle" font-size="9" fill="#666">...</text>
</g>
<!-- Scheduler Deployment -->
<rect x="70" y="335" width="250" height="70" rx="8" fill="url(#deployGrad)" filter="url(#shadowK)"/>
<text x="195" y="362" text-anchor="middle" font-size="12" font-weight="bold" fill="white">Scheduler (CronJob)</text>
<text x="195" y="382" text-anchor="middle" font-size="10" fill="rgba(255,255,255,0.9)">Weekly Auto-Generation</text>
<!-- Monitoring Deployment -->
<rect x="340" y="335" width="250" height="70" rx="8" fill="url(#deployGrad)" filter="url(#shadowK)"/>
<text x="465" y="362" text-anchor="middle" font-size="12" font-weight="bold" fill="white">Monitoring Stack</text>
<text x="465" y="382" text-anchor="middle" font-size="10" fill="rgba(255,255,255,0.9)">Prometheus + Grafana</text>
<!-- StatefulSets Section -->
<rect x="50" y="460" width="800" height="95" rx="10" fill="rgba(245,158,11,0.1)" stroke="#f59e0b" stroke-width="1" stroke-dasharray="5,5"/>
<text x="450" y="480" text-anchor="middle" font-size="12" font-weight="bold" fill="#d97706">StatefulSets (Persistent Data)</text>
<!-- PostgreSQL StatefulSet -->
<rect x="100" y="495" width="300" height="50" rx="8" fill="url(#statefulGrad)" filter="url(#shadowK)"/>
<text x="250" y="520" text-anchor="middle" font-size="12" font-weight="bold" fill="white">PostgreSQL (Primary + Replicas)</text>
<text x="250" y="538" text-anchor="middle" font-size="10" fill="rgba(255,255,255,0.9)">PersistentVolumeClaim</text>
<!-- Redis StatefulSet -->
<rect x="500" y="495" width="300" height="50" rx="8" fill="url(#statefulGrad)" filter="url(#shadowK)"/>
<text x="650" y="520" text-anchor="middle" font-size="12" font-weight="bold" fill="white">Redis Cluster (3-node)</text>
<text x="650" y="538" text-anchor="middle" font-size="10" fill="rgba(255,255,255,0.9)">PersistentVolumeClaim</text>
<!-- External Services Section -->
<rect x="50" y="565" width="800" height="60" rx="10" fill="rgba(148,163,184,0.1)" stroke="#94a3b8" stroke-width="1" stroke-dasharray="5,5"/>
<text x="450" y="585" text-anchor="middle" font-size="12" font-weight="bold" fill="#64748b">External Services</text>
<!-- External Service Icons -->
<rect x="90" y="595" width="100" height="25" rx="5" fill="url(#externalGrad)"/>
<text x="140" y="612" text-anchor="middle" font-size="10" fill="white">AWS S3</text>
<rect x="220" y="595" width="100" height="25" rx="5" fill="url(#externalGrad)"/>
<text x="270" y="612" text-anchor="middle" font-size="10" fill="white">Gemini API</text>
<rect x="350" y="595" width="100" height="25" rx="5" fill="url(#externalGrad)"/>
<text x="400" y="612" text-anchor="middle" font-size="10" fill="white">Suno API</text>
<rect x="480" y="595" width="100" height="25" rx="5" fill="url(#externalGrad)"/>
<text x="530" y="612" text-anchor="middle" font-size="10" fill="white">YouTube API</text>
<rect x="610" y="595" width="100" height="25" rx="5" fill="url(#externalGrad)"/>
<text x="660" y="612" text-anchor="middle" font-size="10" fill="white">Instagram</text>
<rect x="740" y="595" width="100" height="25" rx="5" fill="url(#externalGrad)"/>
<text x="790" y="612" text-anchor="middle" font-size="10" fill="white">TikTok</text>
<!-- Legend -->
<rect x="30" y="650" width="840" height="40" rx="5" fill="#f8f9fa" stroke="#e9ecef"/>
<rect x="60" y="663" width="30" height="15" rx="3" fill="url(#ingressGrad)"/>
<text x="100" y="675" font-size="10" fill="#666">Ingress</text>
<rect x="170" y="663" width="30" height="15" rx="3" fill="url(#deployGrad)"/>
<text x="210" y="675" font-size="10" fill="#666">Deployment</text>
<rect x="300" y="663" width="30" height="15" rx="3" fill="url(#podGrad)"/>
<text x="340" y="675" font-size="10" fill="#666">Pod</text>
<rect x="400" y="663" width="30" height="15" rx="3" fill="url(#statefulGrad)"/>
<text x="440" y="675" font-size="10" fill="#666">StatefulSet</text>
<rect x="530" y="663" width="30" height="15" rx="3" fill="url(#externalGrad)"/>
<text x="570" y="675" font-size="10" fill="#666">External</text>
<!-- HPA indicator -->
<text x="700" y="675" font-size="10" fill="#666">HPA = Horizontal Pod Autoscaler</text>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,112 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 650" font-family="Arial, sans-serif">
<defs>
<linearGradient id="serverGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
<linearGradient id="frontendGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#11998e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#38ef7d;stop-opacity:1" />
</linearGradient>
<linearGradient id="backendGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#fc4a1a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f7b733;stop-opacity:1" />
</linearGradient>
<linearGradient id="dbGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#4facfe;stop-opacity:1" />
<stop offset="100%" style="stop-color:#00f2fe;stop-opacity:1" />
</linearGradient>
<linearGradient id="storageGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#a8edea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fed6e3;stop-opacity:1" />
</linearGradient>
<linearGradient id="apiGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#ffecd2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fcb69f;stop-opacity:1" />
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.3"/>
</filter>
</defs>
<!-- Title -->
<text x="400" y="30" text-anchor="middle" font-size="20" font-weight="bold" fill="#333">
CaStAD Single Server Architecture
</text>
<!-- Main Server Box -->
<rect x="100" y="50" width="600" height="420" rx="15" fill="url(#serverGrad)" filter="url(#shadow)" opacity="0.15"/>
<rect x="100" y="50" width="600" height="420" rx="15" fill="none" stroke="url(#serverGrad)" stroke-width="3"/>
<text x="400" y="80" text-anchor="middle" font-size="16" font-weight="bold" fill="#667eea">Single Server</text>
<!-- Frontend Box -->
<rect x="150" y="100" width="500" height="70" rx="10" fill="url(#frontendGrad)" filter="url(#shadow)"/>
<text x="400" y="130" text-anchor="middle" font-size="14" font-weight="bold" fill="white">Frontend</text>
<text x="400" y="150" text-anchor="middle" font-size="12" fill="white">React + Vite (Port 5173)</text>
<!-- Arrow from Frontend to Backend -->
<path d="M 400 170 L 400 190" stroke="#666" stroke-width="2" marker-end="url(#arrowhead)"/>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#666"/>
</marker>
</defs>
<!-- Backend Box -->
<rect x="150" y="190" width="500" height="100" rx="10" fill="url(#backendGrad)" filter="url(#shadow)"/>
<text x="400" y="215" text-anchor="middle" font-size="14" font-weight="bold" fill="white">Backend</text>
<text x="400" y="235" text-anchor="middle" font-size="12" fill="white">Express.js (Port 3001)</text>
<!-- Service boxes inside Backend -->
<rect x="175" y="250" width="120" height="30" rx="5" fill="white" opacity="0.9"/>
<text x="235" y="270" text-anchor="middle" font-size="11" fill="#fc4a1a">Auth Service</text>
<rect x="315" y="250" width="120" height="30" rx="5" fill="white" opacity="0.9"/>
<text x="375" y="270" text-anchor="middle" font-size="11" fill="#fc4a1a">Render Service</text>
<rect x="455" y="250" width="120" height="30" rx="5" fill="white" opacity="0.9"/>
<text x="515" y="270" text-anchor="middle" font-size="11" fill="#fc4a1a">API Proxy</text>
<!-- Arrow from Backend to Database -->
<path d="M 400 290 L 400 310" stroke="#666" stroke-width="2" marker-end="url(#arrowhead)"/>
<!-- Database Box -->
<rect x="150" y="310" width="500" height="60" rx="10" fill="url(#dbGrad)" filter="url(#shadow)"/>
<text x="400" y="340" text-anchor="middle" font-size="14" font-weight="bold" fill="white">Database</text>
<text x="400" y="358" text-anchor="middle" font-size="12" fill="white">SQLite (File-based)</text>
<!-- Arrow from Database to Storage -->
<path d="M 400 370 L 400 390" stroke="#666" stroke-width="2" marker-end="url(#arrowhead)"/>
<!-- File Storage Box -->
<rect x="150" y="390" width="500" height="60" rx="10" fill="url(#storageGrad)" filter="url(#shadow)"/>
<text x="400" y="418" text-anchor="middle" font-size="14" font-weight="bold" fill="#333">File Storage</text>
<text x="400" y="438" text-anchor="middle" font-size="11" fill="#666">Local Filesystem (downloads/, temp/, uploads/)</text>
<!-- External APIs -->
<path d="M 400 470 L 400 500" stroke="#666" stroke-width="2" marker-end="url(#arrowhead)"/>
<!-- Gemini API -->
<rect x="120" y="520" width="150" height="60" rx="10" fill="url(#apiGrad)" filter="url(#shadow)"/>
<text x="195" y="545" text-anchor="middle" font-size="13" font-weight="bold" fill="#333">Gemini API</text>
<text x="195" y="565" text-anchor="middle" font-size="10" fill="#666">AI Content</text>
<!-- Suno API -->
<rect x="325" y="520" width="150" height="60" rx="10" fill="url(#apiGrad)" filter="url(#shadow)"/>
<text x="400" y="545" text-anchor="middle" font-size="13" font-weight="bold" fill="#333">Suno API</text>
<text x="400" y="565" text-anchor="middle" font-size="10" fill="#666">AI Music</text>
<!-- YouTube API -->
<rect x="530" y="520" width="150" height="60" rx="10" fill="url(#apiGrad)" filter="url(#shadow)"/>
<text x="605" y="545" text-anchor="middle" font-size="13" font-weight="bold" fill="#333">YouTube API</text>
<text x="605" y="565" text-anchor="middle" font-size="10" fill="#666">Video Upload</text>
<!-- Connecting lines to APIs -->
<path d="M 300 500 L 195 520" stroke="#666" stroke-width="1.5" stroke-dasharray="5,5"/>
<path d="M 400 500 L 400 520" stroke="#666" stroke-width="1.5" stroke-dasharray="5,5"/>
<path d="M 500 500 L 605 520" stroke="#666" stroke-width="1.5" stroke-dasharray="5,5"/>
<!-- Capacity Label -->
<rect x="620" y="60" width="70" height="25" rx="5" fill="#e74c3c"/>
<text x="655" y="78" text-anchor="middle" font-size="11" fill="white">~50 users</text>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

140
nginx.castad.conf Normal file
View File

@ -0,0 +1,140 @@
# CaStAD Nginx Configuration
# Target: castad1.ktenterprise.net
#
# 설치 방법:
# sudo cp nginx.castad.conf /etc/nginx/sites-available/castad
# sudo ln -s /etc/nginx/sites-available/castad /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
#
upstream castad_backend {
server 127.0.0.1:3001;
keepalive 64;
}
upstream castad_instagram {
server 127.0.0.1:5001;
keepalive 32;
}
# HTTP → HTTPS 리다이렉트
server {
listen 80;
server_name castad1.ktenterprise.net;
return 301 https://$server_name$request_uri;
}
# HTTPS 메인 서버
server {
listen 443 ssl http2;
server_name castad1.ktenterprise.net;
# SSL 인증서 (Let's Encrypt 사용 시)
ssl_certificate /etc/letsencrypt/live/castad1.ktenterprise.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/castad1.ktenterprise.net/privkey.pem;
# SSL 보안 설정
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# 보안 헤더
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 클라이언트 요청 크기 (이미지 업로드용)
client_max_body_size 100M;
client_body_timeout 300s;
# Gzip 압축
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;
# 로그
access_log /var/log/nginx/castad_access.log;
error_log /var/log/nginx/castad_error.log;
# 정적 파일 (빌드된 프론트엔드)
root /home/castad/castad/dist;
index index.html;
# API 프록시 (백엔드)
location /api/ {
proxy_pass http://castad_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# 렌더링 엔드포인트 (긴 타임아웃)
location /render/ {
proxy_pass http://castad_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s; # 10분 (영상 렌더링용)
proxy_connect_timeout 75s;
proxy_buffering off;
}
# 다운로드 파일
location /downloads/ {
proxy_pass http://castad_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 임시 파일 (렌더링 미리보기)
location /temp/ {
proxy_pass http://castad_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Instagram API (Python 서비스)
location /instagram/ {
proxy_pass http://castad_instagram/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s;
}
# SPA 라우팅 (React Router)
location / {
try_files $uri $uri/ /index.html;
# 정적 에셋 캐싱
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# 헬스체크
location /health {
proxy_pass http://castad_backend/api/health;
proxy_http_version 1.1;
access_log off;
}
}

219
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "bizvibe---ai-music-video-generator", "name": "castad-ai-marketing-video-platform",
"version": "0.5.0", "version": "3.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bizvibe---ai-music-video-generator", "name": "castad-ai-marketing-video-platform",
"version": "0.5.0", "version": "3.0.0",
"dependencies": { "dependencies": {
"@ffmpeg/core": "0.12.6", "@ffmpeg/core": "0.12.6",
"@ffmpeg/ffmpeg": "0.12.10", "@ffmpeg/ffmpeg": "0.12.10",
@ -32,6 +32,7 @@
"dom": "^0.0.3", "dom": "^0.0.3",
"lottie-react": "^2.4.1", "lottie-react": "^2.4.1",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"mammoth": "^1.11.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@ -3550,6 +3551,15 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@ -3624,6 +3634,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/aria-hidden": { "node_modules/aria-hidden": {
"version": "1.2.6", "version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@ -3732,6 +3751,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@ -4099,6 +4124,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -4165,6 +4196,12 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/dlv": { "node_modules/dlv": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@ -4178,6 +4215,15 @@
"integrity": "sha512-Uzda1zIAXO8JG2fm6IbJcdzBrRaC5Q308HTIjCXCQHh7ZVACJOeQzYYvd99plJ2/HUpZQk9IxNI/Y+QrO6poIQ==", "integrity": "sha512-Uzda1zIAXO8JG2fm6IbJcdzBrRaC5Q308HTIjCXCQHh7ZVACJOeQzYYvd99plJ2/HUpZQk9IxNI/Y+QrO6poIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -4592,6 +4638,18 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -4663,6 +4721,12 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -4736,6 +4800,18 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jwa": { "node_modules/jwa": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@ -4757,6 +4833,15 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@ -4777,6 +4862,17 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/lottie-react": { "node_modules/lottie-react": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/lottie-react/-/lottie-react-2.4.1.tgz", "resolved": "https://registry.npmjs.org/lottie-react/-/lottie-react-2.4.1.tgz",
@ -4815,6 +4911,30 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/mammoth": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz",
"integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -5008,12 +5128,33 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -5255,6 +5396,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -5424,6 +5571,27 @@
"pify": "^2.3.0" "pify": "^2.3.0"
} }
}, },
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -5625,6 +5793,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -5691,6 +5865,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -5988,6 +6183,12 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/underscore": {
"version": "1.13.7",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==",
"license": "MIT"
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@ -6082,7 +6283,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
@ -6296,6 +6496,15 @@
} }
} }
}, },
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -1,7 +1,7 @@
{ {
"name": "castad-ai-marketing-video-platform", "name": "castad-ai-marketing-video-platform",
"private": true, "private": true,
"version": "3.0.0", "version": "3.7.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently \"vite\" \"cd server && node index.js\"", "dev": "concurrently \"vite\" \"cd server && node index.js\"",
@ -35,6 +35,7 @@
"dom": "^0.0.3", "dom": "^0.0.3",
"lottie-react": "^2.4.1", "lottie-react": "^2.4.1",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"mammoth": "^1.11.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",

337
plan.md Normal file
View File

@ -0,0 +1,337 @@
# CaStAD UX 레벨 시스템 구현 계획
## 개요
사용자 경험 레벨에 따라 UI 복잡도를 조절하는 3단계 시스템 구현
---
## 1. 사용자 레벨 정의
| 레벨 | 이름 | 대상 | 핵심 철학 |
|------|------|------|----------|
| **Beginner** | 쌩초보 | IT 초보, 빠른 결과 원함 | "알아서 다 해줘" |
| **Intermediate** | 중급 | 약간의 커스터마이징 원함 | "몇 가지만 선택할게" |
| **Pro** | 프로 | 모든 옵션 제어 원함 | "내가 다 컨트롤" |
---
## 2. 레벨별 기능 매트릭스
### 2.1 회원가입/온보딩
| 기능 | Beginner | Intermediate | Pro |
|------|----------|--------------|-----|
| 펜션 정보 입력 | 1개 필수 | 1개 필수 | 선택 |
| 지도 URL 자동 수집 | O | O | O |
| 사진 업로드 | 최소 3장 | 최소 3장 | 선택 |
### 2.2 새 프로젝트 생성
| 옵션 | Beginner | Intermediate | Pro |
|------|----------|--------------|-----|
| 펜션 선택 | 자동 (기본 펜션) | 선택 가능 | 선택 가능 |
| 사진 선택 | 펜션 사진 자동 로드 | 펜션 사진 자동 로드 | 직접 업로드 |
| 사진 편집 | 삭제/추가만 | 삭제/추가/AI생성 | 전체 |
| **언어** | 한국어 고정 | 선택 가능 | 선택 가능 |
| **음악 장르** | AI 자동 선택 | 선택 가능 | 선택 가능 |
| **음악 길이** | Auto | 선택 가능 | 선택 가능 |
| **오디오 모드** | Song 고정 | 선택 가능 | 선택 가능 |
| **영상 스타일** | Video 고정 | 선택 가능 | 선택 가능 |
| **텍스트 이펙트** | Auto 랜덤 | 선택 가능 | 선택 가능 |
| **전환 효과** | Mix 고정 | 선택 가능 | 선택 가능 |
| **영상 비율** | 9:16 고정 | 선택 가능 | 선택 가능 |
| **축제 연동** | X | X | O |
| **AI 이미지 생성** | X | O | O |
| **커스텀 CSS** | X | X | O |
### 2.3 결과 및 업로드
| 기능 | Beginner | Intermediate | Pro |
|------|----------|--------------|-----|
| 미리보기 | O | O | O |
| YouTube 업로드 | **자동** | **자동** | 수동/자동 선택 |
| SEO 최적화 화면 | X (자동) | X (자동) | O (편집 가능) |
| 썸네일 선택 | X (자동) | O | O |
| Instagram 업로드 | 자동 | 자동 | 수동/자동 |
| TikTok 업로드 | 자동 | 자동 | 수동/자동 |
### 2.4 사이드바 메뉴
| 메뉴 | Beginner | Intermediate | Pro |
|------|----------|--------------|-----|
| 대시보드 | O | O | O |
| 새 영상 만들기 | O (간소화) | O | O (전체) |
| 보관함 | O | O | O |
| 에셋 라이브러리 | X | O | O |
| 펜션 관리 | X (1개 고정) | O | O |
| 축제 정보 | X | X | O |
| 계정 관리 | O (간소화) | O | O |
| 비즈니스 설정 | X | O (일부) | O (전체) |
### 2.5 자동화 기능
| 기능 | Beginner | Intermediate | Pro |
|------|----------|--------------|-----|
| 주간 자동 생성 | O (요금제별) | O (요금제별) | O (요금제별) |
| 자동 업로드 | 강제 ON | 강제 ON | 선택 가능 |
| 스케줄 설정 | X | X | O |
---
## 3. 구현 계획
### Phase 1: 기반 인프라 (DB + Context)
#### 3.1 DB 스키마 변경
```sql
-- users 테이블에 컬럼 추가
ALTER TABLE users ADD COLUMN experience_level TEXT DEFAULT 'beginner';
-- 값: 'beginner' | 'intermediate' | 'pro'
```
#### 3.2 UserLevelContext 생성
```typescript
// src/contexts/UserLevelContext.tsx
interface UserLevelContextType {
level: 'beginner' | 'intermediate' | 'pro';
setLevel: (level: string) => void;
features: FeatureFlags;
}
interface FeatureFlags {
showLanguageSelector: boolean;
showMusicGenreSelector: boolean;
showTextEffectSelector: boolean;
showTransitionSelector: boolean;
showAspectRatioSelector: boolean;
showFestivalIntegration: boolean;
showAssetLibrary: boolean;
showPensionManagement: boolean;
showAdvancedSettings: boolean;
showSeoEditor: boolean;
autoUpload: boolean;
// ... 기타
}
```
### Phase 2: 레벨 선택 UI
#### 3.3 레벨 선택 컴포넌트
- 위치: 계정 설정 또는 온보딩
- 3개 카드 형태로 선택
- 각 레벨별 아이콘 + 설명
- 언제든 변경 가능
```
┌─────────────────────────────────────────────────────────────┐
│ 나에게 맞는 모드를 선택하세요 │
├─────────────────┬─────────────────┬─────────────────────────┤
│ 🌱 쌩초보 │ 🌿 중급 │ 🌳 프로 │
│ │ │ │
│ "다 알아서 해줘" │ "조금만 선택" │ "내가 다 컨트롤" │
│ │ │ │
│ • 원클릭 생성 │ • 장르 선택 │ • 모든 옵션 제어 │
│ • 자동 업로드 │ • 언어 선택 │ • 축제 연동 │
│ • 간단한 메뉴 │ • 기본 메뉴 │ • 고급 설정 │
└─────────────────┴─────────────────┴─────────────────────────┘
```
### Phase 3: UI 조건부 렌더링
#### 3.4 NewProjectView 수정
- useUserLevel() 훅으로 현재 레벨 확인
- 레벨에 따라 옵션 섹션 표시/숨김
- Beginner: 사진만 보여주고 "생성하기" 버튼
- Intermediate: 장르, 언어 섹션 추가
- Pro: 전체 옵션
#### 3.5 Sidebar 수정
- 레벨에 따라 메뉴 아이템 필터링
- Beginner: 대시보드, 새 영상, 보관함, 계정
- Intermediate: + 에셋, 펜션 관리, 설정(일부)
- Pro: 전체 메뉴
#### 3.6 SettingsView 수정
- 레벨에 따라 설정 항목 표시/숨김
- 레벨 변경 UI 추가
### Phase 4: 자동 업로드 로직
#### 3.7 영상 생성 완료 후 자동 업로드
```javascript
// GeneratorPage.tsx의 handleRenderComplete 수정
const handleRenderComplete = async (videoPath) => {
// 기존 로직...
// 자동 업로드 (Beginner/Intermediate 또는 Pro with auto_upload=true)
if (shouldAutoUpload(userLevel, youtubeSettings)) {
await autoUploadToYouTube(videoPath, seoData);
await autoUploadToInstagram(videoPath);
await autoUploadToTikTok(videoPath);
}
};
```
#### 3.8 SEO 자동 생성 (Beginner/Intermediate)
```javascript
// 자동 SEO 생성 함수
const generateAutoSeo = async (businessInfo, language) => {
// Gemini로 자동 생성
return {
title: `${businessInfo.name} - 힐링 펜션`,
description: await generateDescription(businessInfo),
tags: await generateTags(businessInfo),
hashtags: await generateHashtags(businessInfo)
};
};
```
### Phase 5: Beginner 전용 간소화 플로우
#### 3.9 BeginnerProjectFlow 컴포넌트
```
단계 1: 펜션 사진 확인
┌─────────────────────────────────────────┐
│ 📸 이 사진들로 영상을 만들까요? │
│ │
│ [사진1] [사진2] [사진3] [사진4] │
│ │
│ [+ 사진 추가] [🗑️ 선택 삭제] │
│ │
│ [ 🎬 영상 만들기 ] │
└─────────────────────────────────────────┘
단계 2: 생성 중 (프로그레스 바)
┌─────────────────────────────────────────┐
│ 🎵 음악 만드는 중... (30%) │
│ ████████░░░░░░░░░░░░ │
└─────────────────────────────────────────┘
단계 3: 완료 + 자동 업로드
┌─────────────────────────────────────────┐
│ ✅ 영상이 완성되었습니다! │
│ │
│ [미리보기 영상 플레이어] │
│ │
│ ✅ YouTube 업로드 완료 │
│ ✅ Instagram 업로드 완료 │
│ │
│ [ 🏠 대시보드로 ] │
└─────────────────────────────────────────┘
```
---
## 4. 파일 변경 목록
### 신규 파일
1. `src/contexts/UserLevelContext.tsx` - 레벨 Context
2. `src/components/settings/LevelSelector.tsx` - 레벨 선택 UI
3. `src/views/BeginnerProjectView.tsx` - 초보자용 간소화 뷰
4. `server/services/autoUploadService.js` - 자동 업로드 서비스
### 수정 파일
1. `server/db.js` - experience_level 컬럼 추가
2. `server/index.js` - 레벨 관련 API 추가
3. `src/contexts/AuthContext.tsx` - 레벨 정보 포함
4. `src/pages/CastADApp.tsx` - UserLevelProvider 추가
5. `src/components/layout/Sidebar.tsx` - 메뉴 필터링
6. `src/views/NewProjectView.tsx` - 옵션 조건부 렌더링
7. `src/views/SettingsView.tsx` - 레벨 변경 UI
8. `src/pages/GeneratorPage.tsx` - 자동 업로드 로직
9. `types.ts` - UserLevel 타입 추가
---
## 5. 구현 순서
1. **DB 스키마 수정** - experience_level 컬럼 추가
2. **UserLevelContext 생성** - 레벨 관리 Context
3. **API 엔드포인트** - 레벨 조회/수정 API
4. **LevelSelector UI** - 레벨 선택 컴포넌트
5. **Sidebar 수정** - 메뉴 필터링
6. **NewProjectView 수정** - 옵션 조건부 렌더링
7. **BeginnerProjectView** - 초보자 전용 플로우
8. **자동 업로드 로직** - 생성 완료 후 자동 업로드
9. **SettingsView 수정** - 레벨 변경 + 설정 필터링
10. **테스트 및 버그 수정**
---
## 6. 예상 소요 시간
- Phase 1 (기반 인프라): 30분
- Phase 2 (레벨 선택 UI): 30분
- Phase 3 (조건부 렌더링): 1시간
- Phase 4 (자동 업로드): 45분
- Phase 5 (Beginner 플로우): 45분
- 테스트/수정: 30분
**총 예상: 약 4시간**
---
## 7. 확정된 사항
1. **레벨 변경**: 언제든 설정에서 변경 가능
2. **온보딩**: 기본 Beginner로 시작, 설정에서 변경
3. **주간 자동 생성**: 함께 구현 (cron job)
---
## 8. 추가 구현: 주간 자동 생성 시스템
### 8.1 DB 스키마 추가
```sql
-- 자동 생성 설정 테이블
CREATE TABLE auto_generation_settings (
id INTEGER PRIMARY KEY,
user_id INTEGER UNIQUE,
enabled INTEGER DEFAULT 0,
day_of_week INTEGER DEFAULT 1, -- 0=일, 1=월, ..., 6=토
time_of_day TEXT DEFAULT '09:00',
last_generated_at DATETIME,
next_scheduled_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 자동 생성 작업 큐
CREATE TABLE generation_queue (
id INTEGER PRIMARY KEY,
user_id INTEGER,
pension_id INTEGER,
status TEXT DEFAULT 'pending', -- pending, processing, completed, failed
scheduled_at DATETIME,
started_at DATETIME,
completed_at DATETIME,
error_message TEXT,
result_video_path TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);
```
### 8.2 Cron Job 서비스
```javascript
// server/services/schedulerService.js
const cron = require('node-cron');
// 매 시간마다 실행 - 예약된 자동 생성 확인
cron.schedule('0 * * * *', async () => {
const pendingJobs = await getPendingGenerationJobs();
for (const job of pendingJobs) {
await processAutoGeneration(job);
}
});
```
### 8.3 자동 생성 플로우
1. 사용자 설정에서 자동 생성 ON + 요일/시간 선택
2. 스케줄러가 매 시간 체크
3. 해당 시간이 되면 generation_queue에 작업 추가
4. 워커가 순차적으로 처리:
- 펜션 정보 로드
- AI 콘텐츠 생성
- 음악 생성
- 영상 렌더링
- 자동 업로드 (YouTube/Instagram/TikTok)
5. 완료 후 이메일/푸시 알림 (선택)

View File

@ -0,0 +1,274 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" font-family="Arial, sans-serif">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f8fafc"/>
<stop offset="100%" style="stop-color:#e2e8f0"/>
</linearGradient>
<linearGradient id="frontendGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#3b82f6"/>
<stop offset="100%" style="stop-color:#1d4ed8"/>
</linearGradient>
<linearGradient id="backendGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#10b981"/>
<stop offset="100%" style="stop-color:#059669"/>
</linearGradient>
<linearGradient id="dbGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#f59e0b"/>
<stop offset="100%" style="stop-color:#d97706"/>
</linearGradient>
<linearGradient id="externalGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#8b5cf6"/>
<stop offset="100%" style="stop-color:#7c3aed"/>
</linearGradient>
<linearGradient id="platformGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#ef4444"/>
<stop offset="100%" style="stop-color:#dc2626"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.2"/>
</filter>
</defs>
<!-- Background -->
<rect width="1200" height="800" fill="url(#bgGrad)"/>
<!-- Title -->
<text x="600" y="40" text-anchor="middle" font-size="24" font-weight="bold" fill="#1e293b">CaStAD System Architecture</text>
<!-- Frontend Section -->
<g transform="translate(50, 70)">
<rect width="320" height="280" rx="12" fill="url(#frontendGrad)" filter="url(#shadow)"/>
<text x="160" y="30" text-anchor="middle" font-size="16" font-weight="bold" fill="white">Frontend (React 19 + Vite)</text>
<rect x="15" y="50" width="290" height="210" rx="8" fill="white" fill-opacity="0.95"/>
<!-- Frontend Components -->
<text x="25" y="75" font-size="11" font-weight="bold" fill="#1e40af">Pages</text>
<text x="25" y="92" font-size="10" fill="#475569">• GeneratorPage (영상 생성)</text>
<text x="25" y="106" font-size="10" fill="#475569">• AdminDashboard (관리자)</text>
<text x="25" y="120" font-size="10" fill="#475569">• PensionsView (펜션 관리)</text>
<text x="25" y="145" font-size="11" font-weight="bold" fill="#1e40af">Services</text>
<text x="25" y="162" font-size="10" fill="#475569">• geminiService (AI 콘텐츠)</text>
<text x="25" y="176" font-size="10" fill="#475569">• sunoService (음악 생성)</text>
<text x="25" y="190" font-size="10" fill="#475569">• naverService (크롤링)</text>
<text x="25" y="204" font-size="10" fill="#475569">• instagramService (크롤링)</text>
<text x="170" y="75" font-size="11" font-weight="bold" fill="#1e40af">Contexts</text>
<text x="170" y="92" font-size="10" fill="#475569">• AuthContext</text>
<text x="170" y="106" font-size="10" fill="#475569">• LanguageContext</text>
<text x="170" y="120" font-size="10" fill="#475569">• ThemeContext</text>
<text x="170" y="145" font-size="11" font-weight="bold" fill="#1e40af">Features</text>
<text x="170" y="162" font-size="10" fill="#475569">• 다국어 지원 (6개)</text>
<text x="170" y="176" font-size="10" fill="#475569">• JWT 인증</text>
<text x="170" y="190" font-size="10" fill="#475569">• OAuth (Google/Naver)</text>
<text x="170" y="204" font-size="10" fill="#475569">• 실시간 프리뷰</text>
<text x="160" y="248" text-anchor="middle" font-size="10" fill="#64748b">Port: 5173 (dev) / 3000 (prod)</text>
</g>
<!-- Backend Section -->
<g transform="translate(440, 70)">
<rect width="320" height="280" rx="12" fill="url(#backendGrad)" filter="url(#shadow)"/>
<text x="160" y="30" text-anchor="middle" font-size="16" font-weight="bold" fill="white">Backend (Express.js)</text>
<rect x="15" y="50" width="290" height="210" rx="8" fill="white" fill-opacity="0.95"/>
<!-- Backend Components -->
<text x="25" y="75" font-size="11" font-weight="bold" fill="#065f46">API Endpoints (107+)</text>
<text x="25" y="92" font-size="10" fill="#475569">• /api/auth/* (인증)</text>
<text x="25" y="106" font-size="10" fill="#475569">• /api/profile/* (펜션)</text>
<text x="25" y="120" font-size="10" fill="#475569">• /api/youtube/* (YouTube)</text>
<text x="25" y="134" font-size="10" fill="#475569">• /api/instagram/* (Instagram)</text>
<text x="25" y="148" font-size="10" fill="#475569">• /api/tiktok/* (TikTok)</text>
<text x="25" y="162" font-size="10" fill="#475569">• /api/gemini/* (AI)</text>
<text x="25" y="176" font-size="10" fill="#475569">• /api/admin/* (관리자)</text>
<text x="170" y="75" font-size="11" font-weight="bold" fill="#065f46">Core Services</text>
<text x="170" y="92" font-size="10" fill="#475569">• Puppeteer (렌더링)</text>
<text x="170" y="106" font-size="10" fill="#475569">• FFmpeg (영상 병합)</text>
<text x="170" y="120" font-size="10" fill="#475569">• JWT 인증</text>
<text x="170" y="134" font-size="10" fill="#475569">• 쿠키 기반 크롤링</text>
<text x="170" y="148" font-size="10" fill="#475569">• API 사용량 추적</text>
<text x="170" y="162" font-size="10" fill="#475569">• 자동 생성 스케줄러</text>
<text x="160" y="248" text-anchor="middle" font-size="10" fill="#64748b">Port: 3001</text>
</g>
<!-- Database Section -->
<g transform="translate(830, 70)">
<rect width="320" height="280" rx="12" fill="url(#dbGrad)" filter="url(#shadow)"/>
<text x="160" y="30" text-anchor="middle" font-size="16" font-weight="bold" fill="white">Database (SQLite)</text>
<rect x="15" y="50" width="290" height="210" rx="8" fill="white" fill-opacity="0.95"/>
<!-- Database Tables -->
<text x="25" y="75" font-size="11" font-weight="bold" fill="#92400e">Core Tables (30+)</text>
<text x="25" y="92" font-size="10" fill="#475569">• users (사용자/플랜/크레딧)</text>
<text x="25" y="106" font-size="10" fill="#475569">• pension_profiles (펜션)</text>
<text x="25" y="120" font-size="10" fill="#475569">• pension_images (이미지)</text>
<text x="25" y="134" font-size="10" fill="#475569">• history (영상 이력)</text>
<text x="25" y="148" font-size="10" fill="#475569">• youtube_connections</text>
<text x="25" y="162" font-size="10" fill="#475569">• instagram_connections</text>
<text x="25" y="176" font-size="10" fill="#475569">• tiktok_connections</text>
<text x="170" y="75" font-size="11" font-weight="bold" fill="#92400e">Data Tables</text>
<text x="170" y="92" font-size="10" fill="#475569">• festivals (축제)</text>
<text x="170" y="106" font-size="10" fill="#475569">• public_pensions</text>
<text x="170" y="120" font-size="10" fill="#475569">• api_usage_logs</text>
<text x="170" y="134" font-size="10" fill="#475569">• system_settings</text>
<text x="170" y="148" font-size="10" fill="#475569">• activity_logs</text>
<text x="170" y="162" font-size="10" fill="#475569">• credit_history</text>
<text x="160" y="248" text-anchor="middle" font-size="10" fill="#64748b">File: server/database.sqlite</text>
</g>
<!-- External APIs Section -->
<g transform="translate(50, 380)">
<rect width="540" height="180" rx="12" fill="url(#externalGrad)" filter="url(#shadow)"/>
<text x="270" y="30" text-anchor="middle" font-size="16" font-weight="bold" fill="white">External APIs</text>
<rect x="15" y="50" width="510" height="110" rx="8" fill="white" fill-opacity="0.95"/>
<!-- API Groups -->
<g transform="translate(25, 70)">
<rect width="110" height="70" rx="6" fill="#f0f9ff" stroke="#0ea5e9" stroke-width="1"/>
<text x="55" y="20" text-anchor="middle" font-size="11" font-weight="bold" fill="#0369a1">Google</text>
<text x="55" y="38" text-anchor="middle" font-size="9" fill="#475569">Gemini AI</text>
<text x="55" y="52" text-anchor="middle" font-size="9" fill="#475569">Places API</text>
<text x="55" y="66" text-anchor="middle" font-size="9" fill="#475569">OAuth 2.0</text>
</g>
<g transform="translate(145, 70)">
<rect width="110" height="70" rx="6" fill="#fdf4ff" stroke="#d946ef" stroke-width="1"/>
<text x="55" y="20" text-anchor="middle" font-size="11" font-weight="bold" fill="#a21caf">Suno AI</text>
<text x="55" y="38" text-anchor="middle" font-size="9" fill="#475569">Music Gen</text>
<text x="55" y="52" text-anchor="middle" font-size="9" fill="#475569">V5 Model</text>
<text x="55" y="66" text-anchor="middle" font-size="9" fill="#475569">Custom Mode</text>
</g>
<g transform="translate(265, 70)">
<rect width="110" height="70" rx="6" fill="#f0fdf4" stroke="#22c55e" stroke-width="1"/>
<text x="55" y="20" text-anchor="middle" font-size="11" font-weight="bold" fill="#16a34a">Naver</text>
<text x="55" y="38" text-anchor="middle" font-size="9" fill="#475569">Map GraphQL</text>
<text x="55" y="52" text-anchor="middle" font-size="9" fill="#475569">OAuth 2.0</text>
<text x="55" y="66" text-anchor="middle" font-size="9" fill="#475569">Cookie Auth</text>
</g>
<g transform="translate(385, 70)">
<rect width="110" height="70" rx="6" fill="#fff7ed" stroke="#f97316" stroke-width="1"/>
<text x="55" y="20" text-anchor="middle" font-size="11" font-weight="bold" fill="#ea580c">TourAPI</text>
<text x="55" y="38" text-anchor="middle" font-size="9" fill="#475569">축제 정보</text>
<text x="55" y="52" text-anchor="middle" font-size="9" fill="#475569">펜션 정보</text>
<text x="55" y="66" text-anchor="middle" font-size="9" fill="#475569">지역 코드</text>
</g>
</g>
<!-- Upload Platforms Section -->
<g transform="translate(610, 380)">
<rect width="540" height="180" rx="12" fill="url(#platformGrad)" filter="url(#shadow)"/>
<text x="270" y="30" text-anchor="middle" font-size="16" font-weight="bold" fill="white">Upload Platforms</text>
<rect x="15" y="50" width="510" height="110" rx="8" fill="white" fill-opacity="0.95"/>
<!-- Platform Groups -->
<g transform="translate(35, 70)">
<rect width="140" height="70" rx="6" fill="#fef2f2" stroke="#ef4444" stroke-width="1"/>
<text x="70" y="20" text-anchor="middle" font-size="11" font-weight="bold" fill="#dc2626">YouTube</text>
<text x="70" y="38" text-anchor="middle" font-size="9" fill="#475569">OAuth 2.0 연동</text>
<text x="70" y="52" text-anchor="middle" font-size="9" fill="#475569">플레이리스트 관리</text>
<text x="70" y="66" text-anchor="middle" font-size="9" fill="#475569">Analytics API</text>
</g>
<g transform="translate(185, 70)">
<rect width="140" height="70" rx="6" fill="#fdf2f8" stroke="#ec4899" stroke-width="1"/>
<text x="70" y="20" text-anchor="middle" font-size="11" font-weight="bold" fill="#db2777">Instagram</text>
<text x="70" y="38" text-anchor="middle" font-size="9" fill="#475569">Reels 업로드</text>
<text x="70" y="52" text-anchor="middle" font-size="9" fill="#475569">자동 캡션/해시태그</text>
<text x="70" y="66" text-anchor="middle" font-size="9" fill="#475569">주간 통계</text>
</g>
<g transform="translate(335, 70)">
<rect width="140" height="70" rx="6" fill="#f0f0f0" stroke="#171717" stroke-width="1"/>
<text x="70" y="20" text-anchor="middle" font-size="11" font-weight="bold" fill="#171717">TikTok</text>
<text x="70" y="38" text-anchor="middle" font-size="9" fill="#475569">OAuth 2.0 연동</text>
<text x="70" y="52" text-anchor="middle" font-size="9" fill="#475569">Direct Post API</text>
<text x="70" y="66" text-anchor="middle" font-size="9" fill="#475569">개인정보 설정</text>
</g>
</g>
<!-- Data Flow Section -->
<g transform="translate(50, 590)">
<rect width="1100" height="180" rx="12" fill="#1e293b" filter="url(#shadow)"/>
<text x="550" y="30" text-anchor="middle" font-size="16" font-weight="bold" fill="white">Video Generation Pipeline</text>
<!-- Flow Steps -->
<g transform="translate(30, 55)">
<rect width="150" height="100" rx="8" fill="#3b82f6"/>
<text x="75" y="25" text-anchor="middle" font-size="11" font-weight="bold" fill="white">1. 입력</text>
<text x="75" y="45" text-anchor="middle" font-size="9" fill="#dbeafe">펜션 정보</text>
<text x="75" y="60" text-anchor="middle" font-size="9" fill="#dbeafe">이미지 (최대 100장)</text>
<text x="75" y="75" text-anchor="middle" font-size="9" fill="#dbeafe">URL 크롤링</text>
<text x="75" y="90" text-anchor="middle" font-size="9" fill="#dbeafe">(Naver/Google/Insta)</text>
</g>
<path d="M195 105 L225 105" stroke="#94a3b8" stroke-width="2" marker-end="url(#arrowhead)"/>
<g transform="translate(235, 55)">
<rect width="150" height="100" rx="8" fill="#8b5cf6"/>
<text x="75" y="25" text-anchor="middle" font-size="11" font-weight="bold" fill="white">2. AI 콘텐츠 생성</text>
<text x="75" y="45" text-anchor="middle" font-size="9" fill="#ede9fe">Gemini: 광고 카피</text>
<text x="75" y="60" text-anchor="middle" font-size="9" fill="#ede9fe">Gemini: TTS 음성</text>
<text x="75" y="75" text-anchor="middle" font-size="9" fill="#ede9fe">Suno: 배경 음악</text>
<text x="75" y="90" text-anchor="middle" font-size="9" fill="#ede9fe">DNA 분석</text>
</g>
<path d="M400 105 L430 105" stroke="#94a3b8" stroke-width="2"/>
<g transform="translate(440, 55)">
<rect width="150" height="100" rx="8" fill="#10b981"/>
<text x="75" y="25" text-anchor="middle" font-size="11" font-weight="bold" fill="white">3. 영상 렌더링</text>
<text x="75" y="45" text-anchor="middle" font-size="9" fill="#d1fae5">Puppeteer 캡처</text>
<text x="75" y="60" text-anchor="middle" font-size="9" fill="#d1fae5">FFmpeg 병합</text>
<text x="75" y="75" text-anchor="middle" font-size="9" fill="#d1fae5">H.264 + AAC</text>
<text x="75" y="90" text-anchor="middle" font-size="9" fill="#d1fae5">9:16 / 16:9 / 1:1</text>
</g>
<path d="M605 105 L635 105" stroke="#94a3b8" stroke-width="2"/>
<g transform="translate(645, 55)">
<rect width="150" height="100" rx="8" fill="#f59e0b"/>
<text x="75" y="25" text-anchor="middle" font-size="11" font-weight="bold" fill="white">4. 후처리</text>
<text x="75" y="45" text-anchor="middle" font-size="9" fill="#fef3c7">SEO 최적화</text>
<text x="75" y="60" text-anchor="middle" font-size="9" fill="#fef3c7">다국어 제목/설명</text>
<text x="75" y="75" text-anchor="middle" font-size="9" fill="#fef3c7">해시태그 생성</text>
<text x="75" y="90" text-anchor="middle" font-size="9" fill="#fef3c7">썸네일 생성</text>
</g>
<path d="M810 105 L840 105" stroke="#94a3b8" stroke-width="2"/>
<g transform="translate(850, 55)">
<rect width="200" height="100" rx="8" fill="#ef4444"/>
<text x="100" y="25" text-anchor="middle" font-size="11" font-weight="bold" fill="white">5. 멀티 플랫폼 업로드</text>
<text x="100" y="45" text-anchor="middle" font-size="9" fill="#fecaca">YouTube (플레이리스트)</text>
<text x="100" y="60" text-anchor="middle" font-size="9" fill="#fecaca">Instagram (Reels)</text>
<text x="100" y="75" text-anchor="middle" font-size="9" fill="#fecaca">TikTok (Direct Post)</text>
<text x="100" y="90" text-anchor="middle" font-size="9" fill="#fecaca">자동/수동 선택</text>
</g>
</g>
<!-- Arrows -->
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8"/>
</marker>
</defs>
<!-- Connection Lines -->
<path d="M210 350 L210 380" stroke="#64748b" stroke-width="2" stroke-dasharray="5,5"/>
<path d="M600 350 L600 380" stroke="#64748b" stroke-width="2" stroke-dasharray="5,5"/>
<path d="M990 350 L990 380" stroke="#64748b" stroke-width="2" stroke-dasharray="5,5"/>
<path d="M370 210 L440 210" stroke="#64748b" stroke-width="2" marker-end="url(#arrowhead)"/>
<path d="M760 210 L830 210" stroke="#64748b" stroke-width="2" marker-end="url(#arrowhead)"/>
<!-- Version Info -->
<text x="1150" y="790" text-anchor="end" font-size="10" fill="#94a3b8">v3.6.0 - CaStAD Architecture</text>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,196 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 700">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0f172a"/>
<stop offset="100%" style="stop-color:#1e293b"/>
</linearGradient>
<linearGradient id="clientGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#3b82f6"/>
<stop offset="100%" style="stop-color:#1d4ed8"/>
</linearGradient>
<linearGradient id="serverGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#10b981"/>
<stop offset="100%" style="stop-color:#059669"/>
</linearGradient>
<linearGradient id="workerGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#f59e0b"/>
<stop offset="100%" style="stop-color:#d97706"/>
</linearGradient>
<linearGradient id="dbGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#8b5cf6"/>
<stop offset="100%" style="stop-color:#7c3aed"/>
</linearGradient>
<linearGradient id="uploadGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#ec4899"/>
<stop offset="100%" style="stop-color:#db2777"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="2" dy="4" stdDeviation="4" flood-opacity="0.3"/>
</filter>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8"/>
</marker>
<marker id="arrowGreen" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#10b981"/>
</marker>
<marker id="arrowOrange" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#f59e0b"/>
</marker>
</defs>
<!-- Background -->
<rect width="1200" height="700" fill="url(#bgGrad)"/>
<!-- Title -->
<text x="600" y="40" text-anchor="middle" fill="#f8fafc" font-size="24" font-weight="bold" font-family="system-ui">
CastAD v3.7.0 - Background Render Queue System
</text>
<text x="600" y="65" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">
Asynchronous Video Rendering with Credit Management
</text>
<!-- Client Section -->
<rect x="50" y="100" width="280" height="280" rx="12" fill="#1e3a5f" stroke="#3b82f6" stroke-width="2" filter="url(#shadow)"/>
<text x="190" y="130" text-anchor="middle" fill="#60a5fa" font-size="16" font-weight="bold" font-family="system-ui">CLIENT (React)</text>
<!-- Client Components -->
<rect x="70" y="150" width="240" height="50" rx="8" fill="url(#clientGrad)" filter="url(#shadow)"/>
<text x="190" y="180" text-anchor="middle" fill="white" font-size="13" font-weight="500" font-family="system-ui">ResultPlayer.tsx</text>
<rect x="70" y="210" width="115" height="40" rx="6" fill="#2563eb"/>
<text x="127" y="235" text-anchor="middle" fill="white" font-size="11" font-family="system-ui">handleServerDownload()</text>
<rect x="195" y="210" width="115" height="40" rx="6" fill="#2563eb"/>
<text x="252" y="235" text-anchor="middle" fill="white" font-size="11" font-family="system-ui">pollJobStatus()</text>
<rect x="70" y="260" width="240" height="50" rx="8" fill="url(#clientGrad)" filter="url(#shadow)"/>
<text x="190" y="290" text-anchor="middle" fill="white" font-size="13" font-weight="500" font-family="system-ui">LibraryView.tsx</text>
<rect x="70" y="320" width="115" height="40" rx="6" fill="#2563eb"/>
<text x="127" y="340" text-anchor="middle" fill="white" font-size="10" font-family="system-ui">YouTube Upload</text>
<rect x="195" y="320" width="115" height="40" rx="6" fill="#c026d3"/>
<text x="252" y="340" text-anchor="middle" fill="white" font-size="10" font-family="system-ui">Instagram Upload</text>
<!-- Server Section -->
<rect x="400" y="100" width="350" height="400" rx="12" fill="#134e4a" stroke="#10b981" stroke-width="2" filter="url(#shadow)"/>
<text x="575" y="130" text-anchor="middle" fill="#34d399" font-size="16" font-weight="bold" font-family="system-ui">SERVER (Express.js)</text>
<!-- API Endpoints -->
<rect x="420" y="150" width="310" height="120" rx="8" fill="url(#serverGrad)" filter="url(#shadow)"/>
<text x="575" y="175" text-anchor="middle" fill="white" font-size="13" font-weight="bold" font-family="system-ui">Render Queue API</text>
<text x="440" y="200" fill="#d1fae5" font-size="11" font-family="monospace">POST /api/render/start</text>
<text x="640" y="200" fill="#fef3c7" font-size="10" font-family="system-ui">→ jobId, credit deduction</text>
<text x="440" y="220" fill="#d1fae5" font-size="11" font-family="monospace">GET /api/render/status/:id</text>
<text x="670" y="220" fill="#fef3c7" font-size="10" font-family="system-ui">→ progress %</text>
<text x="440" y="240" fill="#d1fae5" font-size="11" font-family="monospace">GET /api/render/jobs</text>
<text x="640" y="240" fill="#fef3c7" font-size="10" font-family="system-ui">→ job list</text>
<text x="440" y="260" fill="#fca5a5" font-size="10" font-family="system-ui">* 계정당 동시 렌더링 1개 제한</text>
<!-- Worker Section -->
<rect x="420" y="290" width="310" height="100" rx="8" fill="url(#workerGrad)" filter="url(#shadow)"/>
<text x="575" y="315" text-anchor="middle" fill="white" font-size="13" font-weight="bold" font-family="system-ui">Background Worker</text>
<text x="440" y="340" fill="#fef3c7" font-size="11" font-family="system-ui">• processRenderQueue()</text>
<text x="440" y="358" fill="#fef3c7" font-size="11" font-family="system-ui">• MAX_CONCURRENT_RENDERS: 3</text>
<text x="440" y="376" fill="#fef3c7" font-size="11" font-family="system-ui">• Puppeteer + FFmpeg</text>
<!-- Upload APIs -->
<rect x="420" y="405" width="310" height="80" rx="8" fill="url(#uploadGrad)" filter="url(#shadow)"/>
<text x="575" y="430" text-anchor="middle" fill="white" font-size="13" font-weight="bold" font-family="system-ui">Platform Upload APIs</text>
<text x="440" y="455" fill="#fce7f3" font-size="11" font-family="monospace">POST /api/youtube/my-upload</text>
<text x="440" y="475" fill="#fce7f3" font-size="11" font-family="monospace">POST /api/instagram/upload</text>
<!-- Database Section -->
<rect x="820" y="100" width="330" height="400" rx="12" fill="#4c1d95" stroke="#8b5cf6" stroke-width="2" filter="url(#shadow)"/>
<text x="985" y="130" text-anchor="middle" fill="#c4b5fd" font-size="16" font-weight="bold" font-family="system-ui">DATABASE (SQLite)</text>
<!-- Tables -->
<rect x="840" y="150" width="290" height="140" rx="8" fill="url(#dbGrad)" filter="url(#shadow)"/>
<text x="985" y="175" text-anchor="middle" fill="white" font-size="13" font-weight="bold" font-family="system-ui">render_jobs</text>
<text x="860" y="200" fill="#e9d5ff" font-size="10" font-family="monospace">id TEXT PRIMARY KEY</text>
<text x="860" y="218" fill="#e9d5ff" font-size="10" font-family="monospace">user_id, pension_id</text>
<text x="860" y="236" fill="#e9d5ff" font-size="10" font-family="monospace">status: pending|processing|completed|failed</text>
<text x="860" y="254" fill="#e9d5ff" font-size="10" font-family="monospace">progress: 0-100</text>
<text x="860" y="272" fill="#e9d5ff" font-size="10" font-family="monospace">credits_charged, credits_refunded</text>
<rect x="840" y="305" width="290" height="80" rx="8" fill="url(#dbGrad)" filter="url(#shadow)"/>
<text x="985" y="330" text-anchor="middle" fill="white" font-size="13" font-weight="bold" font-family="system-ui">credit_history</text>
<text x="860" y="355" fill="#e9d5ff" font-size="10" font-family="monospace">user_id, amount, type</text>
<text x="860" y="373" fill="#e9d5ff" font-size="10" font-family="monospace">description, balance_after</text>
<rect x="840" y="400" width="290" height="80" rx="8" fill="url(#dbGrad)" filter="url(#shadow)"/>
<text x="985" y="425" text-anchor="middle" fill="white" font-size="13" font-weight="bold" font-family="system-ui">history</text>
<text x="860" y="450" fill="#e9d5ff" font-size="10" font-family="monospace">final_video_path</text>
<text x="860" y="468" fill="#e9d5ff" font-size="10" font-family="monospace">business_name, pension_id</text>
<!-- Flow Arrows -->
<!-- Client to Server -->
<path d="M 330 200 L 400 200" stroke="#3b82f6" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
<text x="365" y="195" fill="#60a5fa" font-size="9" font-family="system-ui">1. 렌더링 요청</text>
<!-- Server to DB (save job) -->
<path d="M 730 200 L 820 200" stroke="#10b981" stroke-width="2" fill="none" marker-end="url(#arrowGreen)"/>
<text x="775" y="195" fill="#34d399" font-size="9" font-family="system-ui">2. 작업 저장</text>
<!-- Server immediate response -->
<path d="M 400 220 L 330 220" stroke="#f59e0b" stroke-width="2" fill="none" marker-end="url(#arrowOrange)"/>
<text x="365" y="235" fill="#fbbf24" font-size="9" font-family="system-ui">3. jobId 반환</text>
<!-- Worker processing -->
<path d="M 730 340 L 820 300" stroke="#f59e0b" stroke-width="2" fill="none" marker-end="url(#arrowOrange)"/>
<text x="760" y="310" fill="#fbbf24" font-size="9" font-family="system-ui">4. 진행률</text>
<!-- Polling -->
<path d="M 330 230 C 350 280 350 280 400 280" stroke="#94a3b8" stroke-width="1.5" stroke-dasharray="5,3" fill="none" marker-end="url(#arrowhead)"/>
<text x="340" y="275" fill="#94a3b8" font-size="8" font-family="system-ui">5. 폴링</text>
<!-- Credit Flow Section -->
<rect x="50" y="520" width="500" height="160" rx="12" fill="#1e293b" stroke="#64748b" stroke-width="1" filter="url(#shadow)"/>
<text x="300" y="550" text-anchor="middle" fill="#f8fafc" font-size="14" font-weight="bold" font-family="system-ui">Credit Management Flow</text>
<!-- Credit flow boxes -->
<rect x="70" y="570" width="100" height="45" rx="6" fill="#3b82f6"/>
<text x="120" y="590" text-anchor="middle" fill="white" font-size="10" font-family="system-ui">렌더링 요청</text>
<text x="120" y="605" text-anchor="middle" fill="#bfdbfe" font-size="9" font-family="system-ui">크레딧 체크</text>
<rect x="190" y="570" width="100" height="45" rx="6" fill="#f59e0b"/>
<text x="240" y="590" text-anchor="middle" fill="white" font-size="10" font-family="system-ui">선차감</text>
<text x="240" y="605" text-anchor="middle" fill="#fef3c7" font-size="9" font-family="system-ui">-1 크레딧</text>
<rect x="310" y="570" width="100" height="45" rx="6" fill="#10b981"/>
<text x="360" y="590" text-anchor="middle" fill="white" font-size="10" font-family="system-ui">성공</text>
<text x="360" y="605" text-anchor="middle" fill="#d1fae5" font-size="9" font-family="system-ui">완료</text>
<rect x="310" y="625" width="100" height="45" rx="6" fill="#ef4444"/>
<text x="360" y="645" text-anchor="middle" fill="white" font-size="10" font-family="system-ui">실패</text>
<text x="360" y="660" text-anchor="middle" fill="#fecaca" font-size="9" font-family="system-ui">크레딧 환불</text>
<!-- Credit flow arrows -->
<path d="M 170 592 L 190 592" stroke="#94a3b8" stroke-width="1.5" fill="none" marker-end="url(#arrowhead)"/>
<path d="M 290 592 L 310 592" stroke="#94a3b8" stroke-width="1.5" fill="none" marker-end="url(#arrowhead)"/>
<path d="M 290 600 L 295 600 L 295 647 L 310 647" stroke="#94a3b8" stroke-width="1.5" fill="none" marker-end="url(#arrowhead)"/>
<!-- Constraint boxes -->
<rect x="430" y="570" width="105" height="100" rx="6" fill="#7c3aed"/>
<text x="482" y="595" text-anchor="middle" fill="white" font-size="11" font-weight="bold" font-family="system-ui">제한 사항</text>
<text x="482" y="615" text-anchor="middle" fill="#e9d5ff" font-size="10" font-family="system-ui">계정당: 1개</text>
<text x="482" y="635" text-anchor="middle" fill="#e9d5ff" font-size="10" font-family="system-ui">서버: 3개</text>
<text x="482" y="655" text-anchor="middle" fill="#e9d5ff" font-size="10" font-family="system-ui">동시 렌더링</text>
<!-- Feature Summary -->
<rect x="580" y="520" width="570" height="160" rx="12" fill="#1e293b" stroke="#64748b" stroke-width="1" filter="url(#shadow)"/>
<text x="865" y="550" text-anchor="middle" fill="#f8fafc" font-size="14" font-weight="bold" font-family="system-ui">v3.7.0 New Features</text>
<text x="600" y="580" fill="#60a5fa" font-size="12" font-family="system-ui">• Background Render Queue - 페이지 이동/로그아웃해도 작업 계속</text>
<text x="600" y="605" fill="#34d399" font-size="12" font-family="system-ui">• Credit Pre-deduction - 작업 시작 시 선차감, 실패 시 환불</text>
<text x="600" y="630" fill="#fbbf24" font-size="12" font-family="system-ui">• Per-user Rate Limit - 계정당 동시 렌더링 1개 제한</text>
<text x="600" y="655" fill="#f472b6" font-size="12" font-family="system-ui">• Instagram Upload - ResultPlayer & LibraryView에서 업로드</text>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

309
server/billingService.js Normal file
View File

@ -0,0 +1,309 @@
/**
* Google Cloud Billing Service
* BigQuery를 통해 실제 결제 데이터를 조회합니다.
*/
const { BigQuery } = require('@google-cloud/bigquery');
const path = require('path');
// 환경 변수
const KEY_PATH = process.env.GOOGLE_BILLING_KEY_PATH?.replace('./server/', path.join(__dirname, '/').replace('/server/', '/'));
const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT_ID || 'grand-solstice-477822-s9';
const DATASET_ID = process.env.GOOGLE_BILLING_DATASET_ID || 'billing_export';
const BILLING_TABLE_PREFIX = 'gcp_billing_export';
let bigqueryClient = null;
/**
* BigQuery 클라이언트 초기화
*/
const getBigQueryClient = () => {
if (!bigqueryClient) {
const keyPath = KEY_PATH || path.join(__dirname, 'google-billing-key.json');
bigqueryClient = new BigQuery({
keyFilename: keyPath,
projectId: PROJECT_ID
});
}
return bigqueryClient;
};
/**
* 결제 테이블 이름 찾기
*/
const findBillingTable = async () => {
try {
const bigquery = getBigQueryClient();
const dataset = bigquery.dataset(DATASET_ID);
const [tables] = await dataset.getTables();
const billingTable = tables.find(t =>
t.id.includes(BILLING_TABLE_PREFIX) ||
t.id.includes('cloud_billing')
);
return billingTable ? billingTable.id : null;
} catch (error) {
console.error('[BillingService] 테이블 조회 오류:', error.message);
return null;
}
};
/**
* 서비스별 비용 요약 (최근 N일)
*/
const getCostByService = async (days = 30) => {
try {
const tableName = await findBillingTable();
if (!tableName) {
return { error: '결제 데이터 테이블이 아직 생성되지 않았습니다. 24-48시간 후 다시 시도하세요.' };
}
const bigquery = getBigQueryClient();
const query = `
SELECT
service.description as service_name,
SUM(cost) as total_cost,
SUM(CASE WHEN cost > 0 THEN cost ELSE 0 END) as actual_cost,
currency,
COUNT(*) as usage_count
FROM \`${PROJECT_ID}.${DATASET_ID}.${tableName}\`
WHERE DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL ${days} DAY)
GROUP BY service.description, currency
HAVING total_cost != 0
ORDER BY total_cost DESC
`;
const [rows] = await bigquery.query(query);
return {
period: `${days}`,
services: rows.map(row => ({
serviceName: row.service_name,
totalCost: parseFloat(row.total_cost?.toFixed(6) || 0),
actualCost: parseFloat(row.actual_cost?.toFixed(6) || 0),
currency: row.currency,
usageCount: row.usage_count
})),
totalCost: rows.reduce((sum, r) => sum + (r.actual_cost || 0), 0)
};
} catch (error) {
console.error('[BillingService] 서비스별 비용 조회 오류:', error.message);
return { error: error.message };
}
};
/**
* 일별 비용 추이 (최근 N일)
*/
const getDailyCostTrend = async (days = 30) => {
try {
const tableName = await findBillingTable();
if (!tableName) {
return { error: '결제 데이터 테이블이 아직 생성되지 않았습니다.' };
}
const bigquery = getBigQueryClient();
const query = `
SELECT
DATE(usage_start_time) as date,
SUM(cost) as total_cost,
currency
FROM \`${PROJECT_ID}.${DATASET_ID}.${tableName}\`
WHERE DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL ${days} DAY)
GROUP BY DATE(usage_start_time), currency
ORDER BY date DESC
`;
const [rows] = await bigquery.query(query);
return {
period: `${days}`,
daily: rows.map(row => ({
date: row.date?.value || row.date,
cost: parseFloat(row.total_cost?.toFixed(6) || 0),
currency: row.currency
}))
};
} catch (error) {
console.error('[BillingService] 일별 비용 조회 오류:', error.message);
return { error: error.message };
}
};
/**
* SKU별 상세 비용 (Gemini API )
*/
const getCostBySKU = async (days = 30, serviceFilter = null) => {
try {
const tableName = await findBillingTable();
if (!tableName) {
return { error: '결제 데이터 테이블이 아직 생성되지 않았습니다.' };
}
const bigquery = getBigQueryClient();
let whereClause = `DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL ${days} DAY)`;
if (serviceFilter) {
whereClause += ` AND LOWER(service.description) LIKE '%${serviceFilter.toLowerCase()}%'`;
}
const query = `
SELECT
service.description as service_name,
sku.description as sku_name,
SUM(cost) as total_cost,
SUM(usage.amount) as total_usage,
usage.unit as usage_unit,
currency
FROM \`${PROJECT_ID}.${DATASET_ID}.${tableName}\`
WHERE ${whereClause}
GROUP BY service.description, sku.description, usage.unit, currency
HAVING total_cost > 0
ORDER BY total_cost DESC
LIMIT 50
`;
const [rows] = await bigquery.query(query);
return {
period: `${days}`,
filter: serviceFilter,
skus: rows.map(row => ({
serviceName: row.service_name,
skuName: row.sku_name,
totalCost: parseFloat(row.total_cost?.toFixed(6) || 0),
totalUsage: row.total_usage,
usageUnit: row.usage_unit,
currency: row.currency
}))
};
} catch (error) {
console.error('[BillingService] SKU별 비용 조회 오류:', error.message);
return { error: error.message };
}
};
/**
* 월별 비용 요약
*/
const getMonthlyCost = async (months = 6) => {
try {
const tableName = await findBillingTable();
if (!tableName) {
return { error: '결제 데이터 테이블이 아직 생성되지 않았습니다.' };
}
const bigquery = getBigQueryClient();
const query = `
SELECT
FORMAT_DATE('%Y-%m', DATE(usage_start_time)) as month,
service.description as service_name,
SUM(cost) as total_cost,
currency
FROM \`${PROJECT_ID}.${DATASET_ID}.${tableName}\`
WHERE DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL ${months} MONTH)
GROUP BY month, service.description, currency
HAVING total_cost > 0
ORDER BY month DESC, total_cost DESC
`;
const [rows] = await bigquery.query(query);
// 월별로 그룹화
const byMonth = {};
rows.forEach(row => {
if (!byMonth[row.month]) {
byMonth[row.month] = { month: row.month, services: [], total: 0 };
}
byMonth[row.month].services.push({
serviceName: row.service_name,
cost: parseFloat(row.total_cost?.toFixed(6) || 0),
currency: row.currency
});
byMonth[row.month].total += row.total_cost || 0;
});
return {
period: `${months}개월`,
monthly: Object.values(byMonth).map(m => ({
...m,
total: parseFloat(m.total.toFixed(6))
}))
};
} catch (error) {
console.error('[BillingService] 월별 비용 조회 오류:', error.message);
return { error: error.message };
}
};
/**
* Gemini/Vertex AI 비용만 조회
*/
const getGeminiCost = async (days = 30) => {
return getCostBySKU(days, 'vertex ai');
};
/**
* 전체 비용 대시보드 데이터
*/
const getBillingDashboard = async (days = 30) => {
try {
const [byService, dailyTrend, geminiCost] = await Promise.all([
getCostByService(days),
getDailyCostTrend(days),
getGeminiCost(days)
]);
// 총 비용 계산
const totalCost = byService.services?.reduce((sum, s) => sum + s.actualCost, 0) || 0;
// KRW 환산
const usdToKrw = 1350;
return {
period: `${days}`,
summary: {
totalCostUSD: totalCost.toFixed(4),
totalCostKRW: Math.round(totalCost * usdToKrw),
serviceCount: byService.services?.length || 0
},
byService: byService.services || [],
dailyTrend: dailyTrend.daily || [],
geminiUsage: geminiCost.skus || [],
exchangeRate: { USD_KRW: usdToKrw },
dataSource: 'Google Cloud BigQuery (실제 결제 데이터)',
error: byService.error || dailyTrend.error || null
};
} catch (error) {
console.error('[BillingService] 대시보드 데이터 조회 오류:', error.message);
return { error: error.message };
}
};
/**
* 결제 데이터 사용 가능 여부 확인
*/
const checkBillingDataAvailable = async () => {
try {
const tableName = await findBillingTable();
return {
available: !!tableName,
tableName: tableName,
message: tableName
? '결제 데이터를 사용할 수 있습니다.'
: '결제 데이터 테이블이 아직 생성되지 않았습니다. BigQuery Export 설정 후 24-48시간이 필요합니다.'
};
} catch (error) {
return {
available: false,
error: error.message
};
}
};
module.exports = {
getCostByService,
getDailyCostTrend,
getCostBySKU,
getMonthlyCost,
getGeminiCost,
getBillingDashboard,
checkBillingDataAvailable
};

View File

@ -1 +1 @@
{"web":{"client_id":"671727489621-v119rtes771fnrifpmu2pjepja63j4sn.apps.googleusercontent.com","project_id":"grand-solstice-477822-s9","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost:3001/oauth2callback"]}} {"web":{"client_id":"671727489621-v119rtes771fnrifpmu2pjepja63j4sn.apps.googleusercontent.com","project_id":"grand-solstice-477822-s9","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost:3001/api/youtube/oauth/callback","http://localhost:3001/oauth2callback"]}}

View File

@ -55,6 +55,66 @@ db.serialize(() => {
// 구독 만료일 // 구독 만료일
db.run("ALTER TABLE users ADD COLUMN subscription_expires_at DATETIME", (err) => {}); db.run("ALTER TABLE users ADD COLUMN subscription_expires_at DATETIME", (err) => {});
// 사용자 경험 레벨 (beginner, intermediate, pro)
db.run("ALTER TABLE users ADD COLUMN experience_level TEXT DEFAULT 'beginner'", (err) => {});
// ============================================
// 자동 생성 설정 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS auto_generation_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
enabled INTEGER DEFAULT 0,
day_of_week INTEGER DEFAULT 1,
time_of_day TEXT DEFAULT '09:00',
pension_id INTEGER,
last_generated_at DATETIME,
next_scheduled_at DATETIME,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// 자동 업로드 플래그 추가 (Pro 사용자용)
db.run("ALTER TABLE auto_generation_settings ADD COLUMN auto_youtube INTEGER DEFAULT 1", (err) => {});
db.run("ALTER TABLE auto_generation_settings ADD COLUMN auto_instagram INTEGER DEFAULT 1", (err) => {});
db.run("ALTER TABLE auto_generation_settings ADD COLUMN auto_tiktok INTEGER DEFAULT 1", (err) => {});
// ============================================
// 업로드 이력 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS upload_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
history_id INTEGER,
platform TEXT NOT NULL,
status TEXT DEFAULT 'pending',
external_id TEXT,
external_url TEXT,
error_message TEXT,
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(history_id) REFERENCES history(id) ON DELETE CASCADE
)`);
// ============================================
// 자동 생성 작업 큐 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS generation_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pension_id INTEGER,
status TEXT DEFAULT 'pending',
scheduled_at DATETIME,
started_at DATETIME,
completed_at DATETIME,
error_message TEXT,
result_video_path TEXT,
result_history_id INTEGER,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// ============================================ // ============================================
// 펜션/브랜드 프로필 테이블 (다중 펜션 지원) // 펜션/브랜드 프로필 테이블 (다중 펜션 지원)
// ============================================ // ============================================
@ -91,6 +151,76 @@ db.serialize(() => {
db.run("ALTER TABLE pension_profiles ADD COLUMN youtube_playlist_id TEXT", (err) => {}); db.run("ALTER TABLE pension_profiles ADD COLUMN youtube_playlist_id TEXT", (err) => {});
db.run("ALTER TABLE pension_profiles ADD COLUMN youtube_playlist_title TEXT", (err) => {}); db.run("ALTER TABLE pension_profiles ADD COLUMN youtube_playlist_title TEXT", (err) => {});
// ============================================
// 펜션 이미지 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS pension_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pension_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
filename TEXT NOT NULL,
original_url TEXT,
file_path TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT DEFAULT 'image/jpeg',
source TEXT DEFAULT 'crawl',
is_priority INTEGER DEFAULT 0,
used_count INTEGER DEFAULT 0,
last_used_at DATETIME,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// pension_images 우선순위 컬럼 추가 (기존 테이블용)
db.run("ALTER TABLE pension_images ADD COLUMN is_priority INTEGER DEFAULT 0", (err) => {});
db.run("ALTER TABLE pension_images ADD COLUMN used_count INTEGER DEFAULT 0", (err) => {});
db.run("ALTER TABLE pension_images ADD COLUMN last_used_at DATETIME", (err) => {});
// ============================================
// 일일 자동 생성 설정 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS daily_auto_generation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pension_id INTEGER NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
enabled INTEGER DEFAULT 0,
generation_time TEXT DEFAULT '09:00',
image_mode TEXT DEFAULT 'random',
random_count INTEGER DEFAULT 10,
auto_upload_youtube INTEGER DEFAULT 1,
auto_upload_instagram INTEGER DEFAULT 0,
auto_upload_tiktok INTEGER DEFAULT 0,
last_generated_at DATETIME,
next_scheduled_at DATETIME,
consecutive_failures INTEGER DEFAULT 0,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// ============================================
// 자동 생성 로그 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS auto_generation_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pension_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
status TEXT DEFAULT 'pending',
video_path TEXT,
youtube_video_id TEXT,
instagram_media_id TEXT,
tiktok_video_id TEXT,
images_used TEXT,
error_message TEXT,
started_at DATETIME,
completed_at DATETIME,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// ============================================ // ============================================
// YouTube 분석 데이터 캐시 테이블 (펜션별) // YouTube 분석 데이터 캐시 테이블 (펜션별)
// ============================================ // ============================================
@ -451,6 +581,34 @@ db.serialize(() => {
FOREIGN KEY(related_request_id) REFERENCES credit_requests(id) ON DELETE SET NULL FOREIGN KEY(related_request_id) REFERENCES credit_requests(id) ON DELETE SET NULL
)`); )`);
// ============================================
// 렌더링 작업 큐 테이블 (백그라운드 처리)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS render_jobs (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
pension_id INTEGER,
status TEXT DEFAULT 'pending',
progress INTEGER DEFAULT 0,
input_data TEXT,
output_path TEXT,
history_id INTEGER,
error_message TEXT,
credits_charged INTEGER DEFAULT 1,
credits_refunded INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME,
completed_at DATETIME,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE SET NULL,
FOREIGN KEY(history_id) REFERENCES history(id) ON DELETE SET NULL
)`);
// render_jobs 인덱스
db.run("CREATE INDEX IF NOT EXISTS idx_render_jobs_user ON render_jobs(user_id)");
db.run("CREATE INDEX IF NOT EXISTS idx_render_jobs_status ON render_jobs(status)");
db.run("CREATE INDEX IF NOT EXISTS idx_render_jobs_created ON render_jobs(created_at)");
// 기존 테이블에 business_name 컬럼 추가 (존재하지 않을 경우를 대비해 try-catch 대신 별도 실행) // 기존 테이블에 business_name 컬럼 추가 (존재하지 않을 경우를 대비해 try-catch 대신 별도 실행)
// SQLite는 IF NOT EXISTS 컬럼 추가를 지원하지 않으므로, 에러를 무시하는 방식으로 처리하거나 스키마 버전을 관리해야 함. // SQLite는 IF NOT EXISTS 컬럼 추가를 지원하지 않으므로, 에러를 무시하는 방식으로 처리하거나 스키마 버전을 관리해야 함.
// 여기서는 간단히 컬럼 추가 시도 후 에러 무시 패턴을 사용. // 여기서는 간단히 컬럼 추가 시도 후 에러 무시 패턴을 사용.
@ -480,6 +638,239 @@ db.serialize(() => {
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
)`); )`);
// ============================================
// 축제 테이블 (TourAPI 연동)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS festivals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_id TEXT UNIQUE NOT NULL,
content_type_id TEXT DEFAULT '15',
title TEXT NOT NULL,
overview TEXT,
addr1 TEXT,
addr2 TEXT,
zipcode TEXT,
area_code TEXT,
sigungu_code TEXT,
sido TEXT,
sigungu TEXT,
mapx REAL,
mapy REAL,
event_start_date TEXT,
event_end_date TEXT,
first_image TEXT,
first_image2 TEXT,
tel TEXT,
homepage TEXT,
place TEXT,
place_info TEXT,
play_time TEXT,
program TEXT,
use_fee TEXT,
age_limit TEXT,
sponsor1 TEXT,
sponsor1_tel TEXT,
sponsor2 TEXT,
sponsor2_tel TEXT,
sub_event TEXT,
booking_place TEXT,
is_active INTEGER DEFAULT 1,
view_count INTEGER DEFAULT 0,
last_synced_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)`);
// 축제 인덱스
db.run("CREATE INDEX IF NOT EXISTS idx_festivals_area ON festivals(area_code, sigungu_code)");
db.run("CREATE INDEX IF NOT EXISTS idx_festivals_date ON festivals(event_start_date, event_end_date)");
db.run("CREATE INDEX IF NOT EXISTS idx_festivals_coords ON festivals(mapx, mapy)");
// ============================================
// 전국 펜션 마스터 테이블 (TourAPI 연동)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS public_pensions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
source_id TEXT,
content_id TEXT,
name TEXT NOT NULL,
name_normalized TEXT,
address TEXT,
road_address TEXT,
jibun_address TEXT,
zipcode TEXT,
sido TEXT,
sigungu TEXT,
eupmyeondong TEXT,
area_code TEXT,
sigungu_code TEXT,
mapx REAL,
mapy REAL,
tel TEXT,
homepage TEXT,
thumbnail TEXT,
images TEXT,
checkin_time TEXT,
checkout_time TEXT,
room_count INTEGER,
room_type TEXT,
facilities TEXT,
parking TEXT,
reservation_url TEXT,
pet_allowed INTEGER DEFAULT 0,
pickup_available INTEGER DEFAULT 0,
cooking_available INTEGER DEFAULT 0,
barbecue_available INTEGER DEFAULT 0,
business_status TEXT DEFAULT '영업중',
license_date TEXT,
closure_date TEXT,
is_verified INTEGER DEFAULT 0,
is_claimed INTEGER DEFAULT 0,
claimed_by INTEGER,
claimed_at TEXT,
view_count INTEGER DEFAULT 0,
last_synced_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (claimed_by) REFERENCES users(id)
)`);
// 펜션 인덱스
db.run("CREATE INDEX IF NOT EXISTS idx_public_pensions_sido ON public_pensions(sido)");
db.run("CREATE INDEX IF NOT EXISTS idx_public_pensions_sigungu ON public_pensions(sido, sigungu)");
db.run("CREATE INDEX IF NOT EXISTS idx_public_pensions_coords ON public_pensions(mapx, mapy)");
db.run("CREATE INDEX IF NOT EXISTS idx_public_pensions_name ON public_pensions(name_normalized)");
// ============================================
// 펜션-축제 매칭 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS pension_festival_matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pension_id INTEGER NOT NULL,
pension_type TEXT DEFAULT 'public',
festival_id INTEGER NOT NULL,
distance_km REAL,
travel_time_min INTEGER,
match_type TEXT,
match_score INTEGER DEFAULT 0,
is_featured INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (festival_id) REFERENCES festivals(id),
UNIQUE(pension_id, pension_type, festival_id)
)`);
// 매칭 인덱스
db.run("CREATE INDEX IF NOT EXISTS idx_matches_pension ON pension_festival_matches(pension_id, pension_type)");
db.run("CREATE INDEX IF NOT EXISTS idx_matches_festival ON pension_festival_matches(festival_id)");
// ============================================
// 지역코드 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS area_codes (
code TEXT PRIMARY KEY,
name TEXT NOT NULL,
name_short TEXT,
name_en TEXT
)`);
// 지역코드 초기 데이터
const areaCodes = [
['1', '서울특별시', '서울', 'Seoul'],
['2', '인천광역시', '인천', 'Incheon'],
['3', '대전광역시', '대전', 'Daejeon'],
['4', '대구광역시', '대구', 'Daegu'],
['5', '광주광역시', '광주', 'Gwangju'],
['6', '부산광역시', '부산', 'Busan'],
['7', '울산광역시', '울산', 'Ulsan'],
['8', '세종특별자치시', '세종', 'Sejong'],
['31', '경기도', '경기', 'Gyeonggi'],
['32', '강원특별자치도', '강원', 'Gangwon'],
['33', '충청북도', '충북', 'Chungbuk'],
['34', '충청남도', '충남', 'Chungnam'],
['35', '경상북도', '경북', 'Gyeongbuk'],
['36', '경상남도', '경남', 'Gyeongnam'],
['37', '전북특별자치도', '전북', 'Jeonbuk'],
['38', '전라남도', '전남', 'Jeonnam'],
['39', '제주특별자치도', '제주', 'Jeju'],
];
areaCodes.forEach(([code, name, nameShort, nameEn]) => {
db.run("INSERT OR IGNORE INTO area_codes (code, name, name_short, name_en) VALUES (?, ?, ?, ?)",
[code, name, nameShort, nameEn]);
});
// pension_profiles에 좌표 컬럼 추가 (기존 테이블용)
db.run("ALTER TABLE pension_profiles ADD COLUMN mapx REAL", (err) => {});
db.run("ALTER TABLE pension_profiles ADD COLUMN mapy REAL", (err) => {});
db.run("ALTER TABLE pension_profiles ADD COLUMN area_code TEXT", (err) => {});
db.run("ALTER TABLE pension_profiles ADD COLUMN sigungu_code TEXT", (err) => {});
db.run("ALTER TABLE pension_profiles ADD COLUMN public_pension_id INTEGER", (err) => {});
// ============================================
// API 사용량 추적 테이블 (Gemini, Suno 등)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS api_usage_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service TEXT NOT NULL,
model TEXT,
endpoint TEXT,
user_id INTEGER,
tokens_input INTEGER DEFAULT 0,
tokens_output INTEGER DEFAULT 0,
image_count INTEGER DEFAULT 0,
audio_seconds REAL DEFAULT 0,
video_seconds REAL DEFAULT 0,
status TEXT DEFAULT 'success',
error_message TEXT,
latency_ms INTEGER DEFAULT 0,
cost_estimate REAL DEFAULT 0,
metadata TEXT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
)`);
// API 사용량 인덱스
db.run("CREATE INDEX IF NOT EXISTS idx_api_usage_service ON api_usage_logs(service, createdAt)");
db.run("CREATE INDEX IF NOT EXISTS idx_api_usage_user ON api_usage_logs(user_id, createdAt)");
db.run("CREATE INDEX IF NOT EXISTS idx_api_usage_date ON api_usage_logs(createdAt)");
// ============================================
// 시스템 설정 테이블 (쿠키, API 키 등)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS system_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
setting_key TEXT UNIQUE NOT NULL,
setting_value TEXT,
description TEXT,
is_encrypted INTEGER DEFAULT 0,
updated_by INTEGER,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(updated_by) REFERENCES users(id) ON DELETE SET NULL
)`);
// ============================================
// API 일별 집계 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS api_usage_daily (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
service TEXT NOT NULL,
model TEXT,
total_calls INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
error_count INTEGER DEFAULT 0,
total_tokens_input INTEGER DEFAULT 0,
total_tokens_output INTEGER DEFAULT 0,
total_images INTEGER DEFAULT 0,
total_audio_seconds REAL DEFAULT 0,
total_video_seconds REAL DEFAULT 0,
total_cost_estimate REAL DEFAULT 0,
avg_latency_ms REAL DEFAULT 0,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date, service, model)
)`);
// 기본 관리자 계정 생성 (존재하지 않을 경우) // 기본 관리자 계정 생성 (존재하지 않을 경우)
const adminUsername = 'admin'; const adminUsername = 'admin';
const adminPassword = 'admin123'; // 초기 비밀번호 const adminPassword = 'admin123'; // 초기 비밀번호

View File

@ -2,6 +2,209 @@ const { GoogleGenAI, Type, Modality } = require('@google/genai');
const GEMINI_API_KEY = process.env.VITE_GEMINI_API_KEY; // .env에서 API 키 로드 const GEMINI_API_KEY = process.env.VITE_GEMINI_API_KEY; // .env에서 API 키 로드
// ============================================
// API 사용량 추적 유틸리티
// ============================================
let db = null;
try {
db = require('./db');
} catch (e) {
console.warn('DB not available for API usage logging');
}
/**
* API 사용량 로깅
*/
const logApiUsage = async (data) => {
if (!db) return;
const {
service = 'gemini',
model = 'unknown',
endpoint = '',
userId = null,
tokensInput = 0,
tokensOutput = 0,
imageCount = 0,
audioSeconds = 0,
videoSeconds = 0,
status = 'success',
errorMessage = null,
latencyMs = 0,
costEstimate = 0,
metadata = null
} = data;
return new Promise((resolve) => {
db.run(
`INSERT INTO api_usage_logs
(service, model, endpoint, user_id, tokens_input, tokens_output, image_count,
audio_seconds, video_seconds, status, error_message, latency_ms, cost_estimate, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[service, model, endpoint, userId, tokensInput, tokensOutput, imageCount,
audioSeconds, videoSeconds, status, errorMessage, latencyMs, costEstimate,
metadata ? JSON.stringify(metadata) : null],
(err) => {
if (err) console.error('API usage log error:', err);
resolve();
}
);
});
};
// ============================================
// 이미지 생성 모델 우선순위 시스템
// ============================================
const IMAGE_MODELS = [
{
id: 'gemini-2.0-flash-preview-image-generation',
name: 'Gemini 2.0 Flash Image (Preview)',
priority: 1,
costPerImage: 0.02, // 예상 비용 (USD)
maxRetries: 2
},
{
id: 'gemini-2.5-flash-image',
name: 'Gemini 2.5 Flash Image',
priority: 2,
costPerImage: 0.015,
maxRetries: 2
},
{
id: 'imagen-3.0-generate-002',
name: 'Imagen 3',
priority: 3,
costPerImage: 0.03,
maxRetries: 1
}
];
/**
* 이미지 생성을 여러 모델로 시도 (우선순위 기반 폴백)
*/
const generateImageWithFallback = async (ai, prompt, imageParts = [], options = {}) => {
const { aspectRatio = '16:9', userId = null } = options;
const errors = [];
for (const model of IMAGE_MODELS) {
const startTime = Date.now();
for (let retry = 0; retry < model.maxRetries; retry++) {
try {
console.log(`[Image Gen] Trying ${model.name} (attempt ${retry + 1}/${model.maxRetries})`);
let response;
if (model.id.startsWith('imagen')) {
// Imagen 모델용 API
response = await ai.models.generateImages({
model: model.id,
prompt: prompt,
config: {
numberOfImages: 1,
aspectRatio: aspectRatio
}
});
const imageData = response.generatedImages?.[0];
if (imageData?.image?.imageBytes) {
const latency = Date.now() - startTime;
await logApiUsage({
service: 'gemini',
model: model.id,
endpoint: 'generateImages',
userId,
imageCount: 1,
latencyMs: latency,
costEstimate: model.costPerImage,
metadata: { aspectRatio, prompt: prompt.substring(0, 100) }
});
return {
base64: imageData.image.imageBytes,
mimeType: 'image/png',
modelUsed: model.name
};
}
} else {
// Gemini 이미지 모델용 API
response = await ai.models.generateContent({
model: model.id,
contents: {
parts: [
{ text: prompt },
...imageParts
]
},
config: {
responseModalities: [Modality.IMAGE],
}
});
for (const part of response.candidates?.[0]?.content?.parts || []) {
if (part.inlineData) {
const latency = Date.now() - startTime;
await logApiUsage({
service: 'gemini',
model: model.id,
endpoint: 'generateContent/image',
userId,
imageCount: 1,
latencyMs: latency,
costEstimate: model.costPerImage,
metadata: { aspectRatio, prompt: prompt.substring(0, 100) }
});
return {
base64: part.inlineData.data,
mimeType: part.inlineData.mimeType || 'image/png',
modelUsed: model.name
};
}
}
}
throw new Error('No image data in response');
} catch (error) {
const latency = Date.now() - startTime;
const errorMsg = error.message || 'Unknown error';
errors.push({ model: model.name, error: errorMsg, attempt: retry + 1 });
console.warn(`[Image Gen] ${model.name} failed (attempt ${retry + 1}): ${errorMsg}`);
// 로깅 (에러)
await logApiUsage({
service: 'gemini',
model: model.id,
endpoint: 'generateContent/image',
userId,
status: 'error',
errorMessage: errorMsg,
latencyMs: latency
});
// 429 (Rate Limit) 또는 500 에러면 다음 모델로
if (error.status === 429 || error.status === 500 ||
errorMsg.includes('quota') || errorMsg.includes('rate') ||
errorMsg.includes('Internal error')) {
console.log(`[Image Gen] Quota/rate limit hit, trying next model...`);
break; // 다음 모델로
}
// 다른 에러는 재시도
if (retry < model.maxRetries - 1) {
await new Promise(r => setTimeout(r, 1000 * (retry + 1))); // 백오프
}
}
}
}
// 모든 모델 실패
const errorSummary = errors.map(e => `${e.model}: ${e.error}`).join('; ');
throw new Error(`모든 이미지 생성 모델 실패: ${errorSummary}`);
};
const getVoiceName = (config) => { const getVoiceName = (config) => {
if (config.gender === 'Female') { if (config.gender === 'Female') {
if (config.tone === 'Bright' || config.tone === 'Energetic') return 'Zephyr'; if (config.tone === 'Bright' || config.tone === 'Energetic') return 'Zephyr';
@ -78,6 +281,27 @@ const generateCreativeContent = async (info) => {
? info.pensionCategories.map(cat => categoryMap[cat] || cat).join(', ') ? info.pensionCategories.map(cat => categoryMap[cat] || cat).join(', ')
: '일반 펜션'; : '일반 펜션';
// 근처 축제 정보 처리
let festivalContext = '';
if (info.nearbyFestivals && info.nearbyFestivals.length > 0) {
const festivalList = info.nearbyFestivals.map(f => {
let dateStr = '';
if (f.eventstartdate) {
const start = `${f.eventstartdate.slice(0, 4)}.${f.eventstartdate.slice(4, 6)}.${f.eventstartdate.slice(6, 8)}`;
const end = f.eventenddate ? `${f.eventenddate.slice(0, 4)}.${f.eventenddate.slice(4, 6)}.${f.eventenddate.slice(6, 8)}` : '';
dateStr = ` (${start}~${end})`;
}
return `- ${f.title}${dateStr}${f.addr1 ? ` @ ${f.addr1}` : ''}`;
}).join('\n');
festivalContext = `
**근처 축제 정보** (콘텐츠에 자연스럽게 연동하라):
${festivalList}
- 축제들을 언급하며 "축제와 함께하는 특별한 펜션 여행"이라는 메시지를 전달하라.
- 축제 기간에 맞춰 방문할 있는 특별한 경험을 강조하라.
`;
}
const prompt = ` const prompt = `
역할: 전문 ${targetLang} 카피라이터 작사가. 역할: 전문 ${targetLang} 카피라이터 작사가.
클라이언트: ${info.name} 클라이언트: ${info.name}
@ -86,6 +310,7 @@ const generateCreativeContent = async (info) => {
모드: ${info.audioMode} (Song=노래, Narration=내레이션) 모드: ${info.audioMode} (Song=노래, Narration=내레이션)
스타일: ${info.audioMode === 'Song' ? info.musicGenre : info.ttsConfig.tone} 스타일: ${info.audioMode === 'Song' ? info.musicGenre : info.ttsConfig.tone}
언어: **${targetLang}** 작성 필수. 언어: **${targetLang}** 작성 필수.
${festivalContext}
과제 1: 임팩트 있는 **${targetLang}** 광고 헤드라인 4개를 작성하라. 과제 1: 임팩트 있는 **${targetLang}** 광고 헤드라인 4개를 작성하라.
- **완벽한 ${targetLang} 사용**: 자연스럽고 세련된 현지 마케팅 표현 사용. - **완벽한 ${targetLang} 사용**: 자연스럽고 세련된 현지 마케팅 표현 사용.
@ -198,7 +423,7 @@ const generateAdvancedSpeech = async (
}; };
/** /**
* 서버 사이드에서 광고 포스터 생성 - Gemini 3 Pro Image * 서버 사이드에서 광고 포스터 생성 - 모델 우선순위 기반 폴백 시스템
* 업로드된 이미지들을 합성하고 브랜드 분위기에 맞는 고화질 포스터를 생성합니다. * 업로드된 이미지들을 합성하고 브랜드 분위기에 맞는 고화질 포스터를 생성합니다.
* *
* @param {object} info - 비즈니스 정보 객체 (BusinessInfo와 유사) * @param {object} info - 비즈니스 정보 객체 (BusinessInfo와 유사)
@ -207,7 +432,8 @@ const generateAdvancedSpeech = async (
* @param {string} info.description - 브랜드 설명 * @param {string} info.description - 브랜드 설명
* @param {string} info.aspectRatio - 화면 비율 * @param {string} info.aspectRatio - 화면 비율
* @param {boolean} info.useAiImages - AI 이미지 생성 허용 여부 * @param {boolean} info.useAiImages - AI 이미지 생성 허용 여부
* @returns {Promise<{ base64: string; mimeType: string }>} * @param {number} info.userId - 사용자 ID (API 로깅용)
* @returns {Promise<{ base64: string; mimeType: string; modelUsed?: string }>}
*/ */
const generateAdPoster = async (info) => { const generateAdPoster = async (info) => {
if (!GEMINI_API_KEY) { if (!GEMINI_API_KEY) {
@ -219,8 +445,6 @@ const generateAdPoster = async (info) => {
inlineData: { mimeType: imageData.mimeType, data: imageData.base64 } inlineData: { mimeType: imageData.mimeType, data: imageData.base64 }
})); }));
const model = 'gemini-3-pro-image-preview';
let prompt = ''; let prompt = '';
if (imageParts.length > 0) { if (imageParts.length > 0) {
@ -271,22 +495,39 @@ const generateAdPoster = async (info) => {
`; `;
} }
const response = await ai.models.generateContent({ try {
model, // 우선순위 기반 모델 폴백 시스템 사용
contents: { const result = await generateImageWithFallback(ai, prompt, imageParts, {
parts: [ aspectRatio: info.aspectRatio || '16:9',
{ text: prompt }, userId: info.userId
...imageParts });
]
},
});
for (const part of response.candidates?.[0]?.content?.parts || []) { console.log(`[Ad Poster] Generated successfully using: ${result.modelUsed}`);
if (part.inlineData) { return result;
return { base64: part.inlineData.data, mimeType: part.inlineData.mimeType || 'image/png' };
} } catch (error) {
console.error('[Ad Poster] All models failed:', error.message);
// 최종 폴백: 원본 이미지 반환
if (imageParts.length > 0 && info.images[0]) {
console.warn("[Ad Poster] Falling back to original image");
await logApiUsage({
service: 'gemini',
model: 'fallback-original',
endpoint: 'generateAdPoster',
userId: info.userId,
status: 'fallback',
errorMessage: error.message
});
return {
base64: info.images[0].base64,
mimeType: info.images[0].mimeType,
modelUsed: 'Original Image (Fallback)'
};
}
throw error;
} }
throw new Error("광고 포스터 이미지 생성에 실패했습니다.");
}; };
/** /**
@ -300,7 +541,8 @@ const generateImageGallery = async (info, count) => {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다."); throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
} }
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY }); const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const model = 'gemini-3-pro-image-preview'; // gemini-2.5-flash-image: production-ready, stable image generation model
const model = 'gemini-2.5-flash-image';
const perspectives = [ const perspectives = [
"Wide angle shot of the interior, welcoming atmosphere, cinematic lighting", "Wide angle shot of the interior, welcoming atmosphere, cinematic lighting",
@ -324,6 +566,9 @@ const generateImageGallery = async (info, count) => {
const response = await ai.models.generateContent({ const response = await ai.models.generateContent({
model, model,
contents: { parts: [{ text: prompt }] }, contents: { parts: [{ text: prompt }] },
config: {
responseModalities: [Modality.IMAGE],
}
}); });
const part = response.candidates?.[0]?.content?.parts?.[0]; const part = response.candidates?.[0]?.content?.parts?.[0];
@ -792,6 +1037,172 @@ const generateYouTubeSEO = async (params) => {
} }
}; };
/**
* 비즈니스 DNA 분석 (Gemini 2.5 Flash + Google Search Grounding)
* 펜션/숙소의 브랜드 DNA를 분석하여 톤앤매너, 타겟 고객, 컬러, 키워드, 시각적 스타일을 추출
*
* @param {string} nameOrUrl - 펜션 이름 또는 URL
* @param {Array<{base64: string, mimeType: string}>} images - 펜션 이미지들 (선택)
* @param {number} userId - 사용자 ID (로깅용)
* @returns {Promise<object>} - BusinessDNA 객체
*/
const analyzeBusinessDNA = async (nameOrUrl, images = [], userId = null) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const startTime = Date.now();
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
// 이미지 파트 준비
const imageParts = images.map((imageData) => ({
inlineData: { mimeType: imageData.mimeType, data: imageData.base64 }
}));
// Google Search Grounding을 사용하는 프롬프트
const prompt = `
당신은 숙박업 브랜딩 전문가입니다.
**분석 대상**: "${nameOrUrl}"
**과제**: 펜션/숙소에 대해 Google 검색을 수행하여 다음 정보를 수집하고 분석하세요:
1. 공식 웹사이트, 예약 사이트(네이버, 야놀자, 여기어때 ) 정보
2. 고객 리뷰 평점
3. 블로그 포스팅 SNS 언급
4. 사진에서 보이는 인테리어/익스테리어 스타일
${images.length > 0 ? '첨부된 이미지도 분석하여 시각적 스타일을 파악하세요.' : ''}
**출력 형식 (JSON)**:
반드시 아래 구조를 따르세요:
{
"name": "펜션 정식 명칭",
"tagline": "한 줄 슬로건 (10자 이내)",
"toneAndManner": {
"primary": "메인 톤앤매너 (예: Warm & Cozy, Luxurious & Elegant, Modern & Minimal)",
"secondary": "보조 톤앤매너 (선택)",
"description": "톤앤매너에 대한 상세 설명 (50자 내외)"
},
"targetCustomers": {
"primary": "주요 타겟 (예: Young Couples, Families with Kids, Solo Travelers)",
"secondary": ["보조 타겟1", "보조 타겟2"],
"ageRange": "예상 연령대 (예: 25-35)",
"characteristics": ["타겟 특성1", "타겟 특성2", "타겟 특성3"]
},
"brandColors": {
"primary": "#HEX코드 (브랜드 메인 컬러)",
"secondary": "#HEX코드 (보조 컬러)",
"accent": "#HEX코드 (악센트 컬러)",
"palette": ["#컬러1", "#컬러2", "#컬러3", "#컬러4", "#컬러5"],
"mood": "컬러가 주는 분위기 (예: Calm & Serene, Vibrant & Energetic)"
},
"keywords": {
"primary": ["핵심 키워드1", "핵심 키워드2", "핵심 키워드3", "핵심 키워드4", "핵심 키워드5"],
"secondary": ["부가 키워드1", "부가 키워드2", "부가 키워드3"],
"hashtags": ["#해시태그1", "#해시태그2", "#해시태그3", "#해시태그4", "#해시태그5"]
},
"visualStyle": {
"interior": "인테리어 스타일 (예: Scandinavian Minimalist, Korean Modern Hanok, Industrial Loft)",
"exterior": "외관 스타일 (예: Mountain Lodge, Seaside Villa, Forest Cabin)",
"atmosphere": "전반적인 분위기 (예: Serene & Peaceful, Romantic & Intimate, Vibrant & Social)",
"photoStyle": "추천 사진 스타일 (예: Warm Natural Light, Moody & Dramatic, Bright & Airy)",
"suggestedFilters": ["추천 필터1", "추천 필터2", "추천 필터3"]
},
"uniqueSellingPoints": [
"차별화 포인트1 (20자 이내)",
"차별화 포인트2",
"차별화 포인트3"
],
"mood": {
"primary": "메인 무드 (예: Relaxation, Adventure, Romance)",
"emotions": ["고객이 느낄 감정1", "감정2", "감정3", "감정4"]
},
"confidence": 0.85
}
**중요**:
- 모든 필드를 채워야 합니다
- 컬러는 반드시 유효한 HEX 코드로 작성 (#으로 시작)
- 컬러는 펜션의 분위기와 어울리게 선정
- 키워드와 해시태그는 한국어로 작성
- confidence는 정보의 신뢰도 (0.0~1.0)
`;
try {
// Google Search Grounding이 포함된 Gemini 2.5 Flash 호출
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash-preview-05-20',
contents: {
parts: [
{ text: prompt },
...imageParts
]
},
config: {
temperature: 0.7,
responseMimeType: 'application/json',
// Google Search Grounding 활성화
tools: [{ googleSearch: {} }]
}
});
const latency = Date.now() - startTime;
// 로깅
await logApiUsage({
service: 'gemini',
model: 'gemini-2.5-flash-preview-05-20',
endpoint: 'analyze-dna',
userId,
imageCount: images.length,
latencyMs: latency,
costEstimate: 0.01,
metadata: { nameOrUrl, hasImages: images.length > 0 }
});
if (response.text) {
try {
const dna = JSON.parse(response.text);
// 분석 시간 추가
dna.analyzedAt = new Date().toISOString();
// 검색에서 사용된 소스 추출 (grounding metadata)
if (response.candidates?.[0]?.groundingMetadata?.groundingChunks) {
dna.sources = response.candidates[0].groundingMetadata.groundingChunks
.filter(chunk => chunk.web?.uri)
.map(chunk => chunk.web.uri)
.slice(0, 5);
}
return dna;
} catch (e) {
console.error("DNA JSON 파싱 오류:", response.text);
throw new Error("DNA 분석 결과 파싱에 실패했습니다.");
}
}
throw new Error("DNA 분석 응답이 비어있습니다.");
} catch (error) {
const latency = Date.now() - startTime;
await logApiUsage({
service: 'gemini',
model: 'gemini-2.5-flash-preview-05-20',
endpoint: 'analyze-dna',
userId,
status: 'error',
errorMessage: error.message,
latencyMs: latency
});
console.error('[DNA Analysis] 오류:', error.message);
throw error;
}
};
module.exports = { module.exports = {
generateCreativeContent, generateCreativeContent,
generateAdvancedSpeech, generateAdvancedSpeech,
@ -802,4 +1213,5 @@ module.exports = {
extractTextEffectFromImage, extractTextEffectFromImage,
generateVideoBackground, generateVideoBackground,
generateYouTubeSEO, generateYouTubeSEO,
analyzeBusinessDNA,
}; };

File diff suppressed because it is too large Load Diff

View File

@ -139,9 +139,16 @@ def connect_account():
'error_code': 'NO_DATA' 'error_code': 'NO_DATA'
}), 400 }), 400
username = data.get('username', '').strip() # None 값 안전하게 처리
password = data.get('password', '').strip() raw_username = data.get('username')
verification_code = data.get('verification_code', '').strip() raw_password = data.get('password')
raw_verification = data.get('verification_code')
logger.info(f"[DEBUG] raw values - username: {type(raw_username)}, password: {type(raw_password)}, verification: {type(raw_verification)}")
username = str(raw_username).strip() if raw_username else ''
password = str(raw_password).strip() if raw_password else ''
verification_code = str(raw_verification).strip() if raw_verification else ''
if not username or not password: if not username or not password:
return jsonify({ return jsonify({

View File

@ -0,0 +1,263 @@
-- ============================================
-- 축제-펜션 연동 테이블 마이그레이션
-- 버전: 1.0
-- 날짜: 2024-12-08
-- ============================================
-- 축제 테이블
CREATE TABLE IF NOT EXISTS festivals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- TourAPI 식별자
content_id TEXT UNIQUE NOT NULL,
content_type_id TEXT DEFAULT '15',
-- 기본 정보
title TEXT NOT NULL,
overview TEXT,
-- 주소 정보
addr1 TEXT,
addr2 TEXT,
zipcode TEXT,
-- 지역 코드
area_code TEXT,
sigungu_code TEXT,
sido TEXT,
sigungu TEXT,
-- 좌표
mapx REAL,
mapy REAL,
-- 기간
event_start_date TEXT,
event_end_date TEXT,
-- 이미지
first_image TEXT,
first_image2 TEXT,
-- 연락처
tel TEXT,
homepage TEXT,
-- 상세 정보
place TEXT,
place_info TEXT,
play_time TEXT,
program TEXT,
use_fee TEXT,
age_limit TEXT,
-- 주최/주관
sponsor1 TEXT,
sponsor1_tel TEXT,
sponsor2 TEXT,
sponsor2_tel TEXT,
-- 부대정보
sub_event TEXT,
booking_place TEXT,
-- 시스템
is_active INTEGER DEFAULT 1,
view_count INTEGER DEFAULT 0,
last_synced_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
-- 인덱스
CREATE INDEX IF NOT EXISTS idx_festivals_area ON festivals(area_code, sigungu_code);
CREATE INDEX IF NOT EXISTS idx_festivals_date ON festivals(event_start_date, event_end_date);
CREATE INDEX IF NOT EXISTS idx_festivals_coords ON festivals(mapx, mapy);
CREATE INDEX IF NOT EXISTS idx_festivals_active ON festivals(is_active, event_end_date);
-- 전국 펜션 마스터 테이블
CREATE TABLE IF NOT EXISTS public_pensions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- 데이터 출처
source TEXT NOT NULL,
source_id TEXT,
content_id TEXT,
-- 기본 정보
name TEXT NOT NULL,
name_normalized TEXT,
-- 주소 정보
address TEXT,
road_address TEXT,
jibun_address TEXT,
zipcode TEXT,
-- 지역 코드
sido TEXT,
sigungu TEXT,
eupmyeondong TEXT,
area_code TEXT,
sigungu_code TEXT,
-- 좌표
mapx REAL,
mapy REAL,
-- 연락처
tel TEXT,
homepage TEXT,
-- 이미지
thumbnail TEXT,
images TEXT,
-- 숙박 상세
checkin_time TEXT,
checkout_time TEXT,
room_count INTEGER,
room_type TEXT,
facilities TEXT,
parking TEXT,
reservation_url TEXT,
-- 특성
pet_allowed INTEGER DEFAULT 0,
pickup_available INTEGER DEFAULT 0,
cooking_available INTEGER DEFAULT 0,
barbecue_available INTEGER DEFAULT 0,
-- 영업 상태
business_status TEXT DEFAULT '영업중',
license_date TEXT,
closure_date TEXT,
-- 소유권
is_verified INTEGER DEFAULT 0,
is_claimed INTEGER DEFAULT 0,
claimed_by INTEGER,
claimed_at TEXT,
-- 통계
view_count INTEGER DEFAULT 0,
-- 타임스탬프
last_synced_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (claimed_by) REFERENCES users(id)
);
-- 인덱스
CREATE INDEX IF NOT EXISTS idx_public_pensions_sido ON public_pensions(sido);
CREATE INDEX IF NOT EXISTS idx_public_pensions_sigungu ON public_pensions(sido, sigungu);
CREATE INDEX IF NOT EXISTS idx_public_pensions_coords ON public_pensions(mapx, mapy);
CREATE INDEX IF NOT EXISTS idx_public_pensions_name ON public_pensions(name_normalized);
CREATE INDEX IF NOT EXISTS idx_public_pensions_source ON public_pensions(source, source_id);
CREATE INDEX IF NOT EXISTS idx_public_pensions_claimed ON public_pensions(is_claimed, claimed_by);
CREATE UNIQUE INDEX IF NOT EXISTS idx_public_pensions_unique ON public_pensions(source, source_id);
-- 펜션-축제 매칭 테이블
CREATE TABLE IF NOT EXISTS pension_festival_matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pension_id INTEGER NOT NULL,
pension_type TEXT DEFAULT 'public',
festival_id INTEGER NOT NULL,
distance_km REAL,
travel_time_min INTEGER,
match_type TEXT,
match_score INTEGER DEFAULT 0,
is_featured INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (festival_id) REFERENCES festivals(id),
UNIQUE(pension_id, pension_type, festival_id)
);
-- 인덱스
CREATE INDEX IF NOT EXISTS idx_matches_pension ON pension_festival_matches(pension_id, pension_type);
CREATE INDEX IF NOT EXISTS idx_matches_festival ON pension_festival_matches(festival_id);
CREATE INDEX IF NOT EXISTS idx_matches_distance ON pension_festival_matches(distance_km);
-- 지역코드 테이블
CREATE TABLE IF NOT EXISTS area_codes (
code TEXT PRIMARY KEY,
name TEXT NOT NULL,
name_short TEXT,
name_en TEXT
);
-- 시군구코드 테이블
CREATE TABLE IF NOT EXISTS sigungu_codes (
area_code TEXT NOT NULL,
sigungu_code TEXT NOT NULL,
name TEXT NOT NULL,
name_en TEXT,
PRIMARY KEY (area_code, sigungu_code),
FOREIGN KEY (area_code) REFERENCES area_codes(code)
);
-- 펜션 클레임 테이블
CREATE TABLE IF NOT EXISTS pension_claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_pension_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
status TEXT DEFAULT 'pending',
verification_method TEXT,
verification_data TEXT,
reviewed_by INTEGER,
reviewed_at TEXT,
reject_reason TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (public_pension_id) REFERENCES public_pensions(id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (reviewed_by) REFERENCES users(id)
);
-- 지역코드 초기 데이터
INSERT OR IGNORE INTO area_codes (code, name, name_short, name_en) VALUES
('1', '서울특별시', '서울', 'Seoul'),
('2', '인천광역시', '인천', 'Incheon'),
('3', '대전광역시', '대전', 'Daejeon'),
('4', '대구광역시', '대구', 'Daegu'),
('5', '광주광역시', '광주', 'Gwangju'),
('6', '부산광역시', '부산', 'Busan'),
('7', '울산광역시', '울산', 'Ulsan'),
('8', '세종특별자치시', '세종', 'Sejong'),
('31', '경기도', '경기', 'Gyeonggi'),
('32', '강원특별자치도', '강원', 'Gangwon'),
('33', '충청북도', '충북', 'Chungbuk'),
('34', '충청남도', '충남', 'Chungnam'),
('35', '경상북도', '경북', 'Gyeongbuk'),
('36', '경상남도', '경남', 'Gyeongnam'),
('37', '전북특별자치도', '전북', 'Jeonbuk'),
('38', '전라남도', '전남', 'Jeonnam'),
('39', '제주특별자치도', '제주', 'Jeju');
-- pension_profiles 테이블에 컬럼 추가 (이미 존재하면 무시)
-- SQLite는 IF NOT EXISTS를 ALTER TABLE에서 지원하지 않으므로 별도 처리 필요
-- 아래는 참고용, 실제로는 코드에서 처리
-- ALTER TABLE pension_profiles ADD COLUMN mapx REAL;
-- ALTER TABLE pension_profiles ADD COLUMN mapy REAL;
-- ALTER TABLE pension_profiles ADD COLUMN area_code TEXT;
-- ALTER TABLE pension_profiles ADD COLUMN sigungu_code TEXT;
-- ALTER TABLE pension_profiles ADD COLUMN public_pension_id INTEGER REFERENCES public_pensions(id);

1343
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,8 @@
"start": "node index.js" "start": "node index.js"
}, },
"dependencies": { "dependencies": {
"@google-cloud/bigquery": "^8.1.1",
"@google-cloud/billing": "^5.1.1",
"axios": "^1.13.2", "axios": "^1.13.2",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
@ -15,8 +17,9 @@
"googleapis": "^166.0.0", "googleapis": "^166.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^2.0.2", "multer": "^2.0.2",
"node-cron": "^4.2.1",
"open": "^11.0.0", "open": "^11.0.0",
"puppeteer": "^22.0.0", "puppeteer": "^19.0.0",
"puppeteer-screen-recorder": "^3.0.0", "puppeteer-screen-recorder": "^3.0.0",
"resend": "^6.5.2", "resend": "^6.5.2",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"

440
server/scripts/syncData.js Normal file
View File

@ -0,0 +1,440 @@
/**
* 축제 & 펜션 데이터 동기화 스크립트
*
* 사용법:
* node server/scripts/syncData.js # 전체 동기화
* node server/scripts/syncData.js festivals # 축제만
* node server/scripts/syncData.js pensions # 펜션만
* node server/scripts/syncData.js --area=32 # 특정 지역만 (32=강원)
* node server/scripts/syncData.js festivals --startDate=20251201 --endDate=20261231
* # 특정 날짜 범위 축제 동기화
*/
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
const axios = require('axios');
const db = require('../db');
// 설정
const TOURAPI_KEY = process.env.TOURAPI_KEY;
const TOURAPI_ENDPOINT = process.env.TOURAPI_ENDPOINT || 'https://apis.data.go.kr/B551011/KorService2';
// 지역코드 매핑
const AREA_NAMES = {
'1': '서울', '2': '인천', '3': '대전', '4': '대구', '5': '광주',
'6': '부산', '7': '울산', '8': '세종', '31': '경기', '32': '강원',
'33': '충북', '34': '충남', '35': '경북', '36': '경남', '37': '전북',
'38': '전남', '39': '제주',
};
// 유틸리티 함수
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function getTodayString() {
return new Date().toISOString().slice(0, 10).replace(/-/g, '');
}
function getDateAfterMonths(months) {
const date = new Date();
date.setMonth(date.getMonth() + months);
return date.toISOString().slice(0, 10).replace(/-/g, '');
}
function normalizeText(text) {
if (!text) return '';
return text.replace(/\s+/g, '').toLowerCase();
}
// DB 프로미스 래퍼
function dbRun(sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
if (err) reject(err);
else resolve({ lastID: this.lastID, changes: this.changes });
});
});
}
function dbGet(sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
function dbAll(sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
// ============================================
// TourAPI 호출
// ============================================
async function callTourApi(operation, params = {}) {
try {
const url = `${TOURAPI_ENDPOINT}/${operation}`;
const response = await axios.get(url, {
params: {
serviceKey: TOURAPI_KEY,
MobileOS: 'ETC',
MobileApp: 'CastAD',
_type: 'json',
...params,
},
timeout: 30000,
});
const data = response.data;
if (data.response?.header?.resultCode !== '0000') {
throw new Error(data.response?.header?.resultMsg || 'API Error');
}
const body = data.response?.body;
let items = body?.items?.item || [];
// 단일 아이템일 경우 배열로 변환
if (items && !Array.isArray(items)) {
items = [items];
}
return {
items,
totalCount: body?.totalCount || 0,
pageNo: body?.pageNo || 1,
numOfRows: body?.numOfRows || 10,
};
} catch (error) {
if (error.response?.status === 401) {
throw new Error('TourAPI 인증 실패');
}
throw error;
}
}
// ============================================
// 축제 동기화
// ============================================
async function syncFestivals(areaCode = null, customStartDate = null, customEndDate = null) {
console.log('\n========================================');
console.log('🎪 축제 데이터 동기화 시작');
console.log('========================================');
// 날짜 설정: 커스텀 날짜가 있으면 사용, 없으면 기본값 (오늘~1년 후)
const startDate = customStartDate || getTodayString();
const endDate = customEndDate || getDateAfterMonths(12);
console.log(` 기간: ${startDate} ~ ${endDate}`);
let totalSynced = 0;
let pageNo = 1;
const errors = [];
while (true) {
try {
const params = {
numOfRows: 100,
pageNo,
eventStartDate: startDate,
eventEndDate: endDate,
arrange: 'A',
};
if (areaCode) {
params.areaCode = areaCode;
}
console.log(`\n📄 페이지 ${pageNo} 조회 중...`);
const result = await callTourApi('searchFestival2', params);
if (!result.items || result.items.length === 0) {
console.log(' → 더 이상 데이터 없음');
break;
}
console.log(`${result.items.length}건 발견 (전체 ${result.totalCount}건)`);
for (const item of result.items) {
try {
await upsertFestival(item);
totalSynced++;
} catch (err) {
errors.push({ contentId: item.contentid, error: err.message });
}
}
if (result.items.length < 100) break;
pageNo++;
// API 호출 제한 대응
await sleep(200);
} catch (error) {
console.error(` ❌ 페이지 ${pageNo} 에러:`, error.message);
break;
}
}
console.log('\n========================================');
console.log(`✅ 축제 동기화 완료: ${totalSynced}`);
if (errors.length > 0) {
console.log(`⚠️ 에러: ${errors.length}`);
}
console.log('========================================\n');
return { synced: totalSynced, errors };
}
async function upsertFestival(item) {
const exists = await dbGet('SELECT id FROM festivals WHERE content_id = ?', [item.contentid]);
const sido = AREA_NAMES[item.areacode] || null;
if (exists) {
await dbRun(`
UPDATE festivals SET
title = ?, addr1 = ?, addr2 = ?, area_code = ?, sigungu_code = ?, sido = ?,
mapx = ?, mapy = ?, event_start_date = ?, event_end_date = ?,
first_image = ?, first_image2 = ?, tel = ?, zipcode = ?,
last_synced_at = ?, updated_at = CURRENT_TIMESTAMP
WHERE content_id = ?
`, [
item.title, item.addr1, item.addr2, item.areacode, item.sigungucode, sido,
item.mapx ? parseFloat(item.mapx) : null,
item.mapy ? parseFloat(item.mapy) : null,
item.eventstartdate, item.eventenddate,
item.firstimage, item.firstimage2, item.tel, item.zipcode,
new Date().toISOString(),
item.contentid
]);
} else {
await dbRun(`
INSERT INTO festivals (
content_id, content_type_id, title, addr1, addr2, area_code, sigungu_code, sido,
mapx, mapy, event_start_date, event_end_date, first_image, first_image2,
tel, zipcode, last_synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
item.contentid, item.contenttypeid || '15', item.title, item.addr1, item.addr2,
item.areacode, item.sigungucode, sido,
item.mapx ? parseFloat(item.mapx) : null,
item.mapy ? parseFloat(item.mapy) : null,
item.eventstartdate, item.eventenddate, item.firstimage, item.firstimage2,
item.tel, item.zipcode, new Date().toISOString()
]);
}
}
// ============================================
// 펜션 동기화
// ============================================
async function syncPensions(areaCode = null) {
console.log('\n========================================');
console.log('🏡 펜션 데이터 동기화 시작');
console.log('========================================');
let totalSynced = 0;
const errors = [];
// 지역별로 순회 (전체 또는 특정 지역)
const areaCodes = areaCode ? [areaCode] : Object.keys(AREA_NAMES);
for (const area of areaCodes) {
console.log(`\n📍 ${AREA_NAMES[area]} (코드: ${area}) 조회 중...`);
let pageNo = 1;
let areaTotal = 0;
while (true) {
try {
const params = {
numOfRows: 100,
pageNo,
areaCode: area,
arrange: 'A',
};
const result = await callTourApi('searchStay2', params);
if (!result.items || result.items.length === 0) break;
// 펜션만 필터링 (이름에 '펜션' 포함 또는 cat3 코드)
const pensions = result.items.filter(item =>
item.title?.includes('펜션') ||
item.cat3 === 'B02010700' ||
item.title?.toLowerCase().includes('pension')
);
for (const item of pensions) {
try {
await upsertPension(item);
totalSynced++;
areaTotal++;
} catch (err) {
errors.push({ contentId: item.contentid, error: err.message });
}
}
if (result.items.length < 100) break;
pageNo++;
await sleep(200);
} catch (error) {
console.error(`${AREA_NAMES[area]} 에러:`, error.message);
break;
}
}
console.log(`${AREA_NAMES[area]}: ${areaTotal}`);
}
console.log('\n========================================');
console.log(`✅ 펜션 동기화 완료: ${totalSynced}`);
if (errors.length > 0) {
console.log(`⚠️ 에러: ${errors.length}`);
}
console.log('========================================\n');
return { synced: totalSynced, errors };
}
async function upsertPension(item) {
const exists = await dbGet(
'SELECT id FROM public_pensions WHERE source = ? AND content_id = ?',
['TOURAPI', item.contentid]
);
const sido = AREA_NAMES[item.areacode] || null;
const nameNormalized = normalizeText(item.title);
if (exists) {
await dbRun(`
UPDATE public_pensions SET
name = ?, name_normalized = ?, address = ?, area_code = ?, sigungu_code = ?, sido = ?,
mapx = ?, mapy = ?, tel = ?, thumbnail = ?, zipcode = ?,
last_synced_at = ?, updated_at = CURRENT_TIMESTAMP
WHERE source = 'TOURAPI' AND content_id = ?
`, [
item.title, nameNormalized, item.addr1, item.areacode, item.sigungucode, sido,
item.mapx ? parseFloat(item.mapx) : null,
item.mapy ? parseFloat(item.mapy) : null,
item.tel, item.firstimage, item.zipcode,
new Date().toISOString(),
item.contentid
]);
} else {
await dbRun(`
INSERT INTO public_pensions (
source, source_id, content_id, name, name_normalized, address,
area_code, sigungu_code, sido, mapx, mapy, tel, thumbnail, zipcode, last_synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
'TOURAPI', item.contentid, item.contentid, item.title, nameNormalized, item.addr1,
item.areacode, item.sigungucode, sido,
item.mapx ? parseFloat(item.mapx) : null,
item.mapy ? parseFloat(item.mapy) : null,
item.tel, item.firstimage, item.zipcode, new Date().toISOString()
]);
}
}
// ============================================
// 통계 출력
// ============================================
async function printStats() {
console.log('\n========================================');
console.log('📊 현재 데이터 현황');
console.log('========================================');
// 축제 통계
const festivalCount = await dbGet('SELECT COUNT(*) as count FROM festivals');
const festivalByArea = await dbAll(`
SELECT sido, COUNT(*) as count
FROM festivals
WHERE sido IS NOT NULL
GROUP BY sido
ORDER BY count DESC
`);
console.log(`\n🎪 축제: 총 ${festivalCount.count}`);
festivalByArea.forEach(row => {
console.log(` - ${row.sido}: ${row.count}`);
});
// 펜션 통계
const pensionCount = await dbGet('SELECT COUNT(*) as count FROM public_pensions');
const pensionByArea = await dbAll(`
SELECT sido, COUNT(*) as count
FROM public_pensions
WHERE sido IS NOT NULL
GROUP BY sido
ORDER BY count DESC
`);
console.log(`\n🏡 펜션: 총 ${pensionCount.count}`);
pensionByArea.forEach(row => {
console.log(` - ${row.sido}: ${row.count}`);
});
console.log('\n========================================\n');
}
// ============================================
// 메인 실행
// ============================================
async function main() {
const args = process.argv.slice(2);
// 옵션 파싱
let syncType = 'all'; // festivals, pensions, all
let areaCode = null;
let startDate = null;
let endDate = null;
args.forEach(arg => {
if (arg === 'festivals') syncType = 'festivals';
else if (arg === 'pensions') syncType = 'pensions';
else if (arg.startsWith('--area=')) areaCode = arg.split('=')[1];
else if (arg.startsWith('--startDate=')) startDate = arg.split('=')[1];
else if (arg.startsWith('--endDate=')) endDate = arg.split('=')[1];
});
console.log('\n🚀 TourAPI 데이터 동기화');
console.log(` - 유형: ${syncType}`);
console.log(` - 지역: ${areaCode ? AREA_NAMES[areaCode] : '전체'}`);
if (startDate || endDate) {
console.log(` - 날짜 범위: ${startDate || '오늘'} ~ ${endDate || '1년 후'}`);
}
try {
if (syncType === 'all' || syncType === 'festivals') {
await syncFestivals(areaCode, startDate, endDate);
}
if (syncType === 'all' || syncType === 'pensions') {
await syncPensions(areaCode);
}
await printStats();
console.log('✅ 동기화 완료!\n');
process.exit(0);
} catch (error) {
console.error('\n❌ 동기화 실패:', error.message);
process.exit(1);
}
}
// 실행
main();

43
server/scripts/weeklySync.sh Executable file
View File

@ -0,0 +1,43 @@
#!/bin/bash
# ============================================
# 주간 축제/펜션 데이터 동기화 스크립트
# 매주 일요일 새벽 3시 실행 (cron)
# ============================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SERVER_DIR="$(dirname "$SCRIPT_DIR")"
LOG_DIR="$SERVER_DIR/logs"
LOG_FILE="$LOG_DIR/sync_$(date +%Y%m%d_%H%M%S).log"
# 로그 디렉토리 생성
mkdir -p "$LOG_DIR"
echo "============================================" >> "$LOG_FILE"
echo "동기화 시작: $(date)" >> "$LOG_FILE"
echo "============================================" >> "$LOG_FILE"
# Node.js 경로 (nvm 사용 시)
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
cd "$SERVER_DIR"
# 축제 동기화 (2년치)
echo "" >> "$LOG_FILE"
echo "[축제 동기화]" >> "$LOG_FILE"
node scripts/syncData.js festivals --endDate=$(date -d "+2 years" +%Y%m%d) >> "$LOG_FILE" 2>&1
# 펜션 동기화
echo "" >> "$LOG_FILE"
echo "[펜션 동기화]" >> "$LOG_FILE"
node scripts/syncData.js pensions >> "$LOG_FILE" 2>&1
echo "" >> "$LOG_FILE"
echo "============================================" >> "$LOG_FILE"
echo "동기화 완료: $(date)" >> "$LOG_FILE"
echo "============================================" >> "$LOG_FILE"
# 오래된 로그 삭제 (30일 이상)
find "$LOG_DIR" -name "sync_*.log" -mtime +30 -delete
echo "Weekly sync completed. Log: $LOG_FILE"

View File

@ -0,0 +1,311 @@
/**
* Auto Upload Service
*
* Beginner/Intermediate 레벨 사용자의 영상 자동 업로드를 처리합니다.
* - YouTube, Instagram, TikTok에 자동 업로드
* - SEO 데이터 자동 생성 (Gemini 사용)
*/
const db = require('../db');
const path = require('path');
/**
* 사용자 레벨에 따른 자동 업로드 설정 확인
*/
const shouldAutoUpload = async (userId) => {
return new Promise((resolve, reject) => {
const query = `
SELECT u.experience_level, ags.auto_youtube, ags.auto_instagram, ags.auto_tiktok
FROM users u
LEFT JOIN auto_generation_settings ags ON u.id = ags.user_id
WHERE u.id = ?
`;
db.get(query, [userId], (err, row) => {
if (err) {
console.error('자동 업로드 설정 조회 실패:', err);
return resolve({ shouldUpload: false, platforms: {} });
}
if (!row) {
return resolve({ shouldUpload: false, platforms: {} });
}
const level = row.experience_level || 'beginner';
// Beginner/Intermediate는 기본 자동 업로드
if (level === 'beginner' || level === 'intermediate') {
return resolve({
shouldUpload: true,
platforms: {
youtube: true,
instagram: true,
tiktok: true
},
level
});
}
// Pro는 사용자 설정에 따름
if (level === 'pro') {
return resolve({
shouldUpload: row.auto_youtube || row.auto_instagram || row.auto_tiktok,
platforms: {
youtube: Boolean(row.auto_youtube),
instagram: Boolean(row.auto_instagram),
tiktok: Boolean(row.auto_tiktok)
},
level
});
}
resolve({ shouldUpload: false, platforms: {} });
});
});
};
/**
* SEO 데이터 자동 생성 (Gemini 사용)
*/
const generateAutoSeo = async (businessInfo, language = 'KO') => {
const { GoogleGenerativeAI } = require('@google/generative-ai');
const genAI = new GoogleGenerativeAI(process.env.VITE_GEMINI_API_KEY);
const langPrompts = {
KO: {
title: '한국어로',
tags: '한국어 해시태그',
},
EN: {
title: 'in English',
tags: 'English hashtags',
},
JA: {
title: '日本語で',
tags: '日本語ハッシュタグ',
},
ZH: {
title: '用中文',
tags: '中文标签',
},
TH: {
title: 'ในภาษาไทย',
tags: 'แฮชแท็กภาษาไทย',
},
VI: {
title: 'bằng tiếng Việt',
tags: 'hashtag tiếng Việt',
}
};
const langConfig = langPrompts[language] || langPrompts.KO;
const prompt = `
당신은 펜션/숙소 홍보 영상의 SEO 전문가입니다.
아래 정보를 바탕으로 YouTube, Instagram, TikTok용 SEO 데이터를 생성하세요.
펜션 정보:
- 이름: ${businessInfo.name || '펜션'}
- 설명: ${businessInfo.description || ''}
- 주소: ${businessInfo.address || ''}
- 카테고리: ${businessInfo.categories?.join(', ') || ''}
요구사항:
1. title: 매력적인 제목 (${langConfig.title}, 60 이내)
2. description: 상세 설명 (${langConfig.title}, 200 이내)
3. tags: 검색용 태그 10 (${langConfig.tags}, 쉼표로 구분)
4. hashtags: 소셜미디어용 해시태그 15 (${langConfig.tags}, #포함)
JSON 형식으로 응답하세요:
{
"title": "제목",
"description": "설명",
"tags": ["태그1", "태그2", ...],
"hashtags": "#태그1 #태그2 ..."
}
`;
try {
const model = genAI.getGenerativeModel({ model: 'gemini-1.5-flash' });
const result = await model.generateContent(prompt);
const text = result.response.text();
// JSON 추출
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
// 기본값 반환
return {
title: `${businessInfo.name || '펜션'} - 힐링 여행`,
description: businessInfo.description || '아름다운 펜션에서 특별한 휴식을 경험하세요.',
tags: ['펜션', '여행', '힐링', '휴식', '숙소'],
hashtags: '#펜션 #여행 #힐링 #휴식 #숙소 #vacation #travel'
};
} catch (error) {
console.error('SEO 자동 생성 실패:', error);
return {
title: `${businessInfo.name || '펜션'} - 힐링 여행`,
description: businessInfo.description || '아름다운 펜션에서 특별한 휴식을 경험하세요.',
tags: ['펜션', '여행', '힐링', '휴식', '숙소'],
hashtags: '#펜션 #여행 #힐링 #휴식 #숙소 #vacation #travel'
};
}
};
/**
* 자동 업로드 실행
*/
const executeAutoUpload = async (userId, videoPath, businessInfo, language = 'KO') => {
const results = {
youtube: { success: false, error: null },
instagram: { success: false, error: null },
tiktok: { success: false, error: null }
};
try {
// 자동 업로드 설정 확인
const uploadConfig = await shouldAutoUpload(userId);
if (!uploadConfig.shouldUpload) {
console.log('자동 업로드 비활성화 - 사용자 설정에 따름');
return { skipped: true, reason: '자동 업로드 비활성화됨', results };
}
// SEO 데이터 자동 생성
const seoData = await generateAutoSeo(businessInfo, language);
console.log('SEO 데이터 생성 완료:', seoData.title);
// YouTube 업로드
if (uploadConfig.platforms.youtube) {
try {
const youtubeService = require('../youtubeService');
const youtubeResult = await youtubeService.uploadVideo(
userId,
videoPath,
seoData.title,
seoData.description + '\n\n' + seoData.hashtags,
seoData.tags
);
results.youtube = { success: true, videoId: youtubeResult.videoId };
console.log('YouTube 업로드 성공:', youtubeResult.videoId);
} catch (ytError) {
console.error('YouTube 업로드 실패:', ytError.message);
results.youtube = { success: false, error: ytError.message };
}
}
// Instagram 업로드
if (uploadConfig.platforms.instagram) {
try {
// Instagram은 비디오를 릴스로 업로드
const response = await fetch('http://localhost:5001/upload-reel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: await getInstagramUsername(userId),
video_path: videoPath,
caption: seoData.description + '\n\n' + seoData.hashtags
})
});
if (response.ok) {
const result = await response.json();
results.instagram = { success: true, mediaId: result.media_id };
console.log('Instagram 업로드 성공');
} else {
const errorData = await response.json();
results.instagram = { success: false, error: errorData.detail || '업로드 실패' };
}
} catch (igError) {
console.error('Instagram 업로드 실패:', igError.message);
results.instagram = { success: false, error: igError.message };
}
}
// TikTok 업로드
if (uploadConfig.platforms.tiktok) {
try {
const tiktokService = require('../tiktokService');
const tiktokResult = await tiktokService.uploadVideo(
userId,
videoPath,
seoData.description + ' ' + seoData.hashtags
);
results.tiktok = { success: true, videoId: tiktokResult.videoId };
console.log('TikTok 업로드 성공');
} catch (ttError) {
console.error('TikTok 업로드 실패:', ttError.message);
results.tiktok = { success: false, error: ttError.message };
}
}
return {
skipped: false,
level: uploadConfig.level,
seoData,
results
};
} catch (error) {
console.error('자동 업로드 실행 중 오류:', error);
return {
skipped: false,
error: error.message,
results
};
}
};
/**
* 사용자의 Instagram 계정명 가져오기
*/
const getInstagramUsername = async (userId) => {
return new Promise((resolve, reject) => {
db.get(
'SELECT instagram_username FROM social_connections WHERE user_id = ?',
[userId],
(err, row) => {
if (err || !row) {
resolve(null);
} else {
resolve(row.instagram_username);
}
}
);
});
};
/**
* 업로드 이력 저장
*/
const saveUploadHistory = async (userId, historyId, platform, result) => {
return new Promise((resolve, reject) => {
const query = `
INSERT INTO upload_history (user_id, history_id, platform, status, external_id, error_message, uploaded_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
`;
db.run(query, [
userId,
historyId,
platform,
result.success ? 'success' : 'failed',
result.videoId || result.mediaId || null,
result.error || null
], (err) => {
if (err) {
console.error('업로드 이력 저장 실패:', err);
}
resolve();
});
});
};
module.exports = {
shouldAutoUpload,
generateAutoSeo,
executeAutoUpload,
saveUploadHistory
};

View File

@ -0,0 +1,445 @@
/**
* 축제 서비스
* TourAPI 축제 데이터 동기화 관리
*/
const { tourApiClient, AREA_CODE, AREA_NAME } = require('./tourApiClient');
class FestivalService {
constructor(db) {
this.db = db;
}
// ============================================
// 축제 데이터 동기화
// ============================================
/**
* 전체 축제 동기화 (향후 6개월)
*/
async syncAllFestivals() {
console.log('=== 축제 데이터 동기화 시작 ===');
const today = tourApiClient.getTodayString();
const endDate = tourApiClient.getDateAfterMonths(6);
let totalSynced = 0;
let pageNo = 1;
while (true) {
try {
const result = await tourApiClient.searchFestival({
eventStartDate: today,
eventEndDate: endDate,
numOfRows: 100,
pageNo,
arrange: 'A',
});
if (!result.items || result.items.length === 0) break;
// 배열로 변환 (단일 아이템일 경우)
const items = Array.isArray(result.items) ? result.items : [result.items];
for (const item of items) {
await this.upsertFestival(item);
totalSynced++;
}
console.log(` - 페이지 ${pageNo}: ${items.length}건 처리`);
if (items.length < 100) break;
pageNo++;
// API 호출 제한 대응
await this.sleep(100);
} catch (error) {
console.error(` - 페이지 ${pageNo} 에러:`, error.message);
break;
}
}
console.log(`=== 축제 동기화 완료: ${totalSynced}건 ===`);
return totalSynced;
}
/**
* 특정 지역 축제 동기화
*/
async syncFestivalsByArea(areaCode) {
console.log(`=== ${AREA_NAME[areaCode] || areaCode} 축제 동기화 ===`);
const today = tourApiClient.getTodayString();
const endDate = tourApiClient.getDateAfterMonths(6);
let totalSynced = 0;
let pageNo = 1;
while (true) {
const result = await tourApiClient.searchFestival({
eventStartDate: today,
eventEndDate: endDate,
areaCode,
numOfRows: 100,
pageNo,
});
if (!result.items || result.items.length === 0) break;
const items = Array.isArray(result.items) ? result.items : [result.items];
for (const item of items) {
await this.upsertFestival(item);
totalSynced++;
}
if (items.length < 100) break;
pageNo++;
await this.sleep(100);
}
return totalSynced;
}
/**
* 축제 데이터 저장/업데이트
*/
async upsertFestival(item) {
const exists = await this.db.get(
'SELECT id FROM festivals WHERE content_id = ?',
[item.contentid]
);
const data = {
content_id: item.contentid,
content_type_id: item.contenttypeid || '15',
title: item.title,
addr1: item.addr1,
addr2: item.addr2,
area_code: item.areacode,
sigungu_code: item.sigungucode,
sido: AREA_NAME[item.areacode] || null,
mapx: item.mapx ? parseFloat(item.mapx) : null,
mapy: item.mapy ? parseFloat(item.mapy) : null,
event_start_date: item.eventstartdate,
event_end_date: item.eventenddate,
first_image: item.firstimage,
first_image2: item.firstimage2,
tel: item.tel,
last_synced_at: new Date().toISOString(),
};
if (exists) {
await this.db.run(`
UPDATE festivals SET
title = ?, addr1 = ?, addr2 = ?, area_code = ?, sigungu_code = ?, sido = ?,
mapx = ?, mapy = ?, event_start_date = ?, event_end_date = ?,
first_image = ?, first_image2 = ?, tel = ?, last_synced_at = ?,
updated_at = CURRENT_TIMESTAMP
WHERE content_id = ?
`, [
data.title, data.addr1, data.addr2, data.area_code, data.sigungu_code, data.sido,
data.mapx, data.mapy, data.event_start_date, data.event_end_date,
data.first_image, data.first_image2, data.tel, data.last_synced_at,
data.content_id
]);
return { action: 'updated', id: exists.id };
} else {
const result = await this.db.run(`
INSERT INTO festivals (
content_id, content_type_id, title, addr1, addr2, area_code, sigungu_code, sido,
mapx, mapy, event_start_date, event_end_date, first_image, first_image2,
tel, last_synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
data.content_id, data.content_type_id, data.title, data.addr1, data.addr2,
data.area_code, data.sigungu_code, data.sido, data.mapx, data.mapy,
data.event_start_date, data.event_end_date, data.first_image, data.first_image2,
data.tel, data.last_synced_at
]);
return { action: 'inserted', id: result.lastID };
}
}
/**
* 축제 상세 정보 업데이트
*/
async fetchAndUpdateDetail(contentId) {
try {
const detail = await tourApiClient.getFestivalDetail(contentId);
const { common, intro } = detail;
await this.db.run(`
UPDATE festivals SET
overview = ?,
homepage = ?,
place = ?,
place_info = ?,
play_time = ?,
program = ?,
use_fee = ?,
age_limit = ?,
sponsor1 = ?,
sponsor1_tel = ?,
sponsor2 = ?,
sponsor2_tel = ?,
sub_event = ?,
booking_place = ?,
updated_at = CURRENT_TIMESTAMP
WHERE content_id = ?
`, [
common.overview,
common.homepage,
intro.eventplace,
intro.placeinfo,
intro.playtime,
intro.program,
intro.usetimefestival,
intro.agelimit,
intro.sponsor1,
intro.sponsor1tel,
intro.sponsor2,
intro.sponsor2tel,
intro.subevent,
intro.bookingplace,
contentId
]);
return detail;
} catch (error) {
console.error(`축제 상세 조회 실패 (${contentId}):`, error.message);
return null;
}
}
// ============================================
// 축제 조회
// ============================================
/**
* 활성 축제 목록 (종료되지 않은)
*/
async getActiveFestivals(options = {}) {
const {
areaCode = null,
limit = 20,
offset = 0,
} = options;
const today = tourApiClient.getTodayString();
let query = `
SELECT * FROM festivals
WHERE is_active = 1
AND event_end_date >= ?
`;
const params = [today];
if (areaCode) {
query += ' AND area_code = ?';
params.push(areaCode);
}
query += ' ORDER BY event_start_date ASC LIMIT ? OFFSET ?';
params.push(limit, offset);
return this.db.all(query, params);
}
/**
* 진행중인 축제
*/
async getOngoingFestivals(areaCode = null, limit = 20) {
const today = tourApiClient.getTodayString();
let query = `
SELECT * FROM festivals
WHERE is_active = 1
AND event_start_date <= ?
AND event_end_date >= ?
`;
const params = [today, today];
if (areaCode) {
query += ' AND area_code = ?';
params.push(areaCode);
}
query += ' ORDER BY event_end_date ASC LIMIT ?';
params.push(limit);
return this.db.all(query, params);
}
/**
* 예정 축제
*/
async getUpcomingFestivals(areaCode = null, limit = 20) {
const today = tourApiClient.getTodayString();
let query = `
SELECT * FROM festivals
WHERE is_active = 1
AND event_start_date > ?
`;
const params = [today];
if (areaCode) {
query += ' AND area_code = ?';
params.push(areaCode);
}
query += ' ORDER BY event_start_date ASC LIMIT ?';
params.push(limit);
return this.db.all(query, params);
}
/**
* 축제 상세 조회
*/
async getFestivalById(id) {
const festival = await this.db.get('SELECT * FROM festivals WHERE id = ?', [id]);
if (festival && !festival.overview) {
// 상세 정보가 없으면 API에서 가져오기
await this.fetchAndUpdateDetail(festival.content_id);
return this.db.get('SELECT * FROM festivals WHERE id = ?', [id]);
}
return festival;
}
/**
* content_id로 축제 조회
*/
async getFestivalByContentId(contentId) {
return this.db.get('SELECT * FROM festivals WHERE content_id = ?', [contentId]);
}
/**
* 축제 검색
*/
async searchFestivals(keyword, limit = 20) {
return this.db.all(`
SELECT * FROM festivals
WHERE is_active = 1
AND (title LIKE ? OR addr1 LIKE ?)
AND event_end_date >= ?
ORDER BY event_start_date ASC
LIMIT ?
`, [`%${keyword}%`, `%${keyword}%`, tourApiClient.getTodayString(), limit]);
}
/**
* 월별 축제 조회
*/
async getFestivalsByMonth(year, month) {
const startDate = `${year}${String(month).padStart(2, '0')}01`;
const endDate = `${year}${String(month).padStart(2, '0')}31`;
return this.db.all(`
SELECT * FROM festivals
WHERE is_active = 1
AND event_start_date <= ?
AND event_end_date >= ?
ORDER BY event_start_date ASC
`, [endDate, startDate]);
}
/**
* 지역별 축제 통계
*/
async getFestivalStatsByRegion() {
const today = tourApiClient.getTodayString();
return this.db.all(`
SELECT
area_code,
sido,
COUNT(*) as total,
SUM(CASE WHEN event_start_date <= ? AND event_end_date >= ? THEN 1 ELSE 0 END) as ongoing,
SUM(CASE WHEN event_start_date > ? THEN 1 ELSE 0 END) as upcoming
FROM festivals
WHERE is_active = 1 AND event_end_date >= ?
GROUP BY area_code
ORDER BY total DESC
`, [today, today, today, today]);
}
// ============================================
// 좌표 기반 조회
// ============================================
/**
* 좌표 근처 축제 조회 (DB에서)
*/
async getNearbyFestivals(mapX, mapY, radiusKm = 50) {
const today = tourApiClient.getTodayString();
// 대략적인 위경도 범위 계산 (1도 ≈ 111km)
const latRange = radiusKm / 111;
const lngRange = radiusKm / 88; // 한국 위도 기준
const festivals = await this.db.all(`
SELECT * FROM festivals
WHERE is_active = 1
AND event_end_date >= ?
AND mapx IS NOT NULL AND mapy IS NOT NULL
AND mapy BETWEEN ? AND ?
AND mapx BETWEEN ? AND ?
`, [
today,
mapY - latRange, mapY + latRange,
mapX - lngRange, mapX + lngRange,
]);
// 정확한 거리 계산 및 필터링
return festivals
.map(f => ({
...f,
distance_km: this.calculateDistance(mapY, mapX, f.mapy, f.mapx),
}))
.filter(f => f.distance_km <= radiusKm)
.sort((a, b) => a.distance_km - b.distance_km);
}
/**
* 좌표 근처 축제 조회 (API 직접)
*/
async getNearbyFestivalsFromApi(mapX, mapY, radius = 20000) {
return tourApiClient.getNearbyFestivals(mapX, mapY, radius);
}
// ============================================
// 유틸리티
// ============================================
/**
* Haversine 공식으로 거리 계산 (km)
*/
calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371; // 지구 반경 (km)
const dLat = this.toRad(lat2 - lat1);
const dLon = this.toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return Math.round(R * c * 100) / 100; // 소수점 2자리
}
toRad(deg) {
return deg * (Math.PI / 180);
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
module.exports = FestivalService;

View File

@ -0,0 +1,355 @@
/**
* Geocoding 서비스
* 카카오 Local API를 사용한 주소 좌표 변환
*
* 문서: https://developers.kakao.com/docs/latest/ko/local/dev-guide
*/
const axios = require('axios');
class GeocodingService {
constructor() {
this.baseUrl = 'https://dapi.kakao.com/v2/local';
this.apiKey = process.env.KAKAO_REST_KEY;
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 10000,
headers: {
Authorization: `KakaoAK ${this.apiKey}`,
},
});
}
// ============================================
// 주소 → 좌표 변환 (Geocoding)
// ============================================
/**
* 주소를 좌표로 변환
* @param {string} address - 주소 문자열
* @returns {Object|null} { mapx, mapy, address, sido, sigungu, ... }
*/
async geocode(address) {
try {
const response = await this.client.get('/search/address.json', {
params: { query: address },
});
const documents = response.data.documents;
if (!documents || documents.length === 0) {
console.log(`Geocoding: 결과 없음 - "${address}"`);
return null;
}
const result = documents[0];
const addr = result.address || {};
return {
mapx: parseFloat(result.x), // 경도 (longitude)
mapy: parseFloat(result.y), // 위도 (latitude)
address: result.address_name, // 전체 주소
addressType: result.address_type, // REGION, ROAD, ROAD_ADDR
sido: addr.region_1depth_name || null, // 시도
sigungu: addr.region_2depth_name || null, // 시군구
eupmyeondong: addr.region_3depth_name || null, // 읍면동
bCode: addr.b_code || null, // 법정동 코드
hCode: addr.h_code || null, // 행정동 코드
roadAddress: result.road_address?.address_name || null,
};
} catch (error) {
console.error('Geocoding 실패:', address, error.message);
return null;
}
}
/**
* 도로명 주소 검색
*/
async geocodeRoad(address) {
try {
const response = await this.client.get('/search/address.json', {
params: {
query: address,
analyze_type: 'exact', // 정확히 일치
},
});
const documents = response.data.documents;
if (!documents || documents.length === 0) {
return null;
}
// 도로명 주소 결과 우선
const roadResult = documents.find(d => d.road_address);
if (roadResult) {
const road = roadResult.road_address;
return {
mapx: parseFloat(roadResult.x),
mapy: parseFloat(roadResult.y),
address: road.address_name,
roadAddress: road.address_name,
sido: road.region_1depth_name,
sigungu: road.region_2depth_name,
eupmyeondong: road.region_3depth_name,
roadName: road.road_name,
buildingName: road.building_name,
zoneNo: road.zone_no, // 우편번호
};
}
return this.geocode(address);
} catch (error) {
console.error('도로명 Geocoding 실패:', error.message);
return null;
}
}
// ============================================
// 좌표 → 주소 변환 (역지오코딩)
// ============================================
/**
* 좌표를 주소로 변환
* @param {number} mapx - 경도 (longitude)
* @param {number} mapy - 위도 (latitude)
*/
async reverseGeocode(mapx, mapy) {
try {
const response = await this.client.get('/geo/coord2address.json', {
params: {
x: mapx,
y: mapy,
},
});
const documents = response.data.documents;
if (!documents || documents.length === 0) {
return null;
}
const result = documents[0];
const addr = result.address || {};
const road = result.road_address;
return {
address: addr.address_name,
roadAddress: road?.address_name || null,
sido: addr.region_1depth_name, // 시도
sigungu: addr.region_2depth_name, // 시군구
eupmyeondong: addr.region_3depth_name, // 읍면동
hCode: addr.h_code, // 행정동 코드
bCode: addr.b_code, // 법정동 코드
};
} catch (error) {
console.error('역지오코딩 실패:', mapx, mapy, error.message);
return null;
}
}
/**
* 좌표로 행정구역 정보만 조회
*/
async getRegionByCoord(mapx, mapy) {
try {
const response = await this.client.get('/geo/coord2regioncode.json', {
params: {
x: mapx,
y: mapy,
},
});
const documents = response.data.documents;
if (!documents || documents.length === 0) {
return null;
}
// B: 법정동, H: 행정동
const bDong = documents.find(d => d.region_type === 'B');
const hDong = documents.find(d => d.region_type === 'H');
return {
sido: bDong?.region_1depth_name || hDong?.region_1depth_name,
sigungu: bDong?.region_2depth_name || hDong?.region_2depth_name,
eupmyeondong: bDong?.region_3depth_name || hDong?.region_3depth_name,
bCode: bDong?.code,
hCode: hDong?.code,
};
} catch (error) {
console.error('행정구역 조회 실패:', error.message);
return null;
}
}
// ============================================
// 키워드 검색
// ============================================
/**
* 키워드로 장소 검색
* @param {string} keyword - 검색어 (: "가평 펜션")
* @param {Object} options - { x, y, radius, page, size }
*/
async searchKeyword(keyword, options = {}) {
try {
const params = {
query: keyword,
page: options.page || 1,
size: options.size || 15,
};
// 중심 좌표가 있으면 거리순 정렬
if (options.x && options.y) {
params.x = options.x;
params.y = options.y;
params.sort = 'distance';
if (options.radius) {
params.radius = Math.min(options.radius, 20000); // 최대 20km
}
}
const response = await this.client.get('/search/keyword.json', params);
return {
places: response.data.documents.map(place => ({
id: place.id,
name: place.place_name,
category: place.category_name,
phone: place.phone,
address: place.address_name,
roadAddress: place.road_address_name,
mapx: parseFloat(place.x),
mapy: parseFloat(place.y),
placeUrl: place.place_url,
distance: place.distance ? parseInt(place.distance) : null,
})),
meta: response.data.meta,
};
} catch (error) {
console.error('키워드 검색 실패:', error.message);
return { places: [], meta: {} };
}
}
/**
* 펜션 검색 (지역 + 펜션 키워드)
*/
async searchPensions(region, options = {}) {
return this.searchKeyword(`${region} 펜션`, options);
}
// ============================================
// 지역코드 매핑
// ============================================
/**
* 시도명 TourAPI 지역코드 변환
*/
getAreaCode(sido) {
const areaCodeMap = {
'서울': '1', '서울특별시': '1',
'인천': '2', '인천광역시': '2',
'대전': '3', '대전광역시': '3',
'대구': '4', '대구광역시': '4',
'광주': '5', '광주광역시': '5',
'부산': '6', '부산광역시': '6',
'울산': '7', '울산광역시': '7',
'세종': '8', '세종특별자치시': '8',
'경기': '31', '경기도': '31',
'강원': '32', '강원도': '32', '강원특별자치도': '32',
'충북': '33', '충청북도': '33',
'충남': '34', '충청남도': '34',
'경북': '35', '경상북도': '35',
'경남': '36', '경상남도': '36',
'전북': '37', '전라북도': '37', '전북특별자치도': '37',
'전남': '38', '전라남도': '38',
'제주': '39', '제주특별자치도': '39',
};
return areaCodeMap[sido] || null;
}
/**
* 좌표에서 TourAPI 지역코드 조회
*/
async getAreaCodeFromCoords(mapx, mapy) {
const region = await this.getRegionByCoord(mapx, mapy);
if (!region) return null;
return {
areaCode: this.getAreaCode(region.sido),
sido: region.sido,
sigungu: region.sigungu,
};
}
// ============================================
// 주소 파싱 유틸리티
// ============================================
/**
* 주소 문자열에서 시도/시군구 추출
*/
parseAddress(address) {
if (!address) return { sido: null, sigungu: null };
const sidoPatterns = [
{ pattern: /^(서울특별시|서울)/, name: '서울' },
{ pattern: /^(부산광역시|부산)/, name: '부산' },
{ pattern: /^(대구광역시|대구)/, name: '대구' },
{ pattern: /^(인천광역시|인천)/, name: '인천' },
{ pattern: /^(광주광역시|광주)/, name: '광주' },
{ pattern: /^(대전광역시|대전)/, name: '대전' },
{ pattern: /^(울산광역시|울산)/, name: '울산' },
{ pattern: /^(세종특별자치시|세종)/, name: '세종' },
{ pattern: /^(경기도|경기)/, name: '경기' },
{ pattern: /^(강원특별자치도|강원도|강원)/, name: '강원' },
{ pattern: /^(충청북도|충북)/, name: '충북' },
{ pattern: /^(충청남도|충남)/, name: '충남' },
{ pattern: /^(전북특별자치도|전라북도|전북)/, name: '전북' },
{ pattern: /^(전라남도|전남)/, name: '전남' },
{ pattern: /^(경상북도|경북)/, name: '경북' },
{ pattern: /^(경상남도|경남)/, name: '경남' },
{ pattern: /^(제주특별자치도|제주)/, name: '제주' },
];
let sido = null;
let sigungu = null;
for (const { pattern, name } of sidoPatterns) {
const match = address.match(pattern);
if (match) {
sido = name;
// 시군구 추출
const remaining = address.slice(match[0].length).trim();
const sigunguMatch = remaining.match(/^([가-힣]+[시군구])/);
if (sigunguMatch) {
sigungu = sigunguMatch[1];
}
break;
}
}
return { sido, sigungu, areaCode: this.getAreaCode(sido) };
}
/**
* 주소 정규화 (검색용)
*/
normalizeAddress(address) {
if (!address) return '';
return address
.replace(/특별시|광역시|특별자치시|특별자치도/g, '')
.replace(/\s+/g, ' ')
.trim();
}
}
// 싱글톤 인스턴스
const geocodingService = new GeocodingService();
module.exports = {
geocodingService,
GeocodingService,
};

View File

@ -0,0 +1,249 @@
/**
* Scheduler Service
*
* 주간 자동 영상 생성을 위한 cron job 스케줄러
* - 시간마다 예약된 작업 확인
* - generation_queue에 작업 추가
* - 워커가 순차적으로 처리
*/
const cron = require('node-cron');
const db = require('../db');
const path = require('path');
/**
* 예약된 자동 생성 작업 확인
*/
const getPendingGenerationJobs = () => {
return new Promise((resolve, reject) => {
const now = new Date().toISOString();
db.all(`
SELECT ags.*, u.username, u.email, pp.brand_name, pp.description, pp.address, pp.pension_types
FROM auto_generation_settings ags
JOIN users u ON ags.user_id = u.id
LEFT JOIN pension_profiles pp ON ags.pension_id = pp.id
WHERE ags.enabled = 1
AND ags.next_scheduled_at <= ?
AND NOT EXISTS (
SELECT 1 FROM generation_queue gq
WHERE gq.user_id = ags.user_id
AND gq.status IN ('pending', 'processing')
)
`, [now], (err, rows) => {
if (err) {
console.error('예약 작업 조회 실패:', err);
return resolve([]);
}
resolve(rows || []);
});
});
};
/**
* 작업 큐에 추가
*/
const addToQueue = (userId, pensionId) => {
return new Promise((resolve, reject) => {
db.run(`
INSERT INTO generation_queue (user_id, pension_id, status, scheduled_at)
VALUES (?, ?, 'pending', datetime('now'))
`, [userId, pensionId], function(err) {
if (err) {
console.error('큐 추가 실패:', err);
return resolve(null);
}
resolve(this.lastID);
});
});
};
/**
* 다음 예약 시간 업데이트
*/
const updateNextScheduledTime = (userId, dayOfWeek, timeOfDay) => {
return new Promise((resolve, reject) => {
const now = new Date();
const [hours, minutes] = (timeOfDay || '09:00').split(':').map(Number);
let next = new Date(now);
next.setHours(hours, minutes, 0, 0);
// 다음 주 같은 요일로 설정
const daysUntilNext = (dayOfWeek - now.getDay() + 7) % 7 || 7;
next.setDate(next.getDate() + daysUntilNext);
const nextIso = next.toISOString();
db.run(`
UPDATE auto_generation_settings
SET next_scheduled_at = ?, last_generated_at = datetime('now')
WHERE user_id = ?
`, [nextIso, userId], (err) => {
if (err) console.error('다음 예약 시간 업데이트 실패:', err);
resolve(nextIso);
});
});
};
/**
* 큐에서 대기 중인 작업 처리
*/
const processPendingQueue = async () => {
return new Promise((resolve, reject) => {
db.get(`
SELECT gq.*, pp.brand_name, pp.description, pp.address, pp.pension_types, pp.user_id
FROM generation_queue gq
LEFT JOIN pension_profiles pp ON gq.pension_id = pp.id
WHERE gq.status = 'pending'
ORDER BY gq.scheduled_at ASC
LIMIT 1
`, async (err, job) => {
if (err || !job) {
return resolve(null);
}
console.log(`[Scheduler] 작업 처리 시작: 사용자 ${job.user_id}, 펜션 ${job.pension_id}`);
// 상태를 processing으로 변경
db.run(`
UPDATE generation_queue
SET status = 'processing', started_at = datetime('now')
WHERE id = ?
`, [job.id]);
try {
// 영상 생성 로직 실행
const result = await generateVideoForJob(job);
// 성공 시 상태 업데이트
db.run(`
UPDATE generation_queue
SET status = 'completed', completed_at = datetime('now'),
result_video_path = ?, result_history_id = ?
WHERE id = ?
`, [result.videoPath, result.historyId, job.id]);
console.log(`[Scheduler] 작업 완료: ${job.id}`);
resolve(result);
} catch (error) {
console.error(`[Scheduler] 작업 실패: ${job.id}`, error);
// 실패 시 상태 업데이트
db.run(`
UPDATE generation_queue
SET status = 'failed', completed_at = datetime('now'),
error_message = ?
WHERE id = ?
`, [error.message, job.id]);
resolve(null);
}
});
});
};
/**
* 실제 영상 생성 실행
* TODO: 실제 영상 생성 로직과 연동 필요
*/
const generateVideoForJob = async (job) => {
// 펜션 정보 기반으로 영상 생성
const businessInfo = {
name: job.brand_name || '펜션',
description: job.description || '',
address: job.address || '',
pensionCategories: job.pension_types ? JSON.parse(job.pension_types) : []
};
// 실제 영상 생성 API 호출 (내부 API 사용)
// 현재는 placeholder - 실제 구현 시 render API 호출 필요
console.log('[Scheduler] 영상 생성 요청:', businessInfo.name);
// TODO: 실제 영상 생성 로직 구현
// const response = await fetch('http://localhost:3001/render', { ... });
return {
videoPath: null, // 실제 생성된 비디오 경로
historyId: null // 히스토리 ID
};
};
/**
* 스케줄러 시작
*/
const startScheduler = () => {
console.log('[Scheduler] 자동 생성 스케줄러 시작');
// 매 시간 정각에 실행 (0분)
cron.schedule('0 * * * *', async () => {
console.log(`[Scheduler] 예약 작업 확인 중... ${new Date().toISOString()}`);
try {
// 예약된 작업 확인
const pendingJobs = await getPendingGenerationJobs();
console.log(`[Scheduler] ${pendingJobs.length}개의 예약 작업 발견`);
for (const job of pendingJobs) {
// 큐에 작업 추가
await addToQueue(job.user_id, job.pension_id);
// 다음 예약 시간 업데이트
await updateNextScheduledTime(job.user_id, job.day_of_week, job.time_of_day);
console.log(`[Scheduler] 작업 예약됨: 사용자 ${job.user_id}`);
}
// 대기 중인 작업 처리
await processPendingQueue();
} catch (error) {
console.error('[Scheduler] 스케줄러 오류:', error);
}
});
// 10분마다 큐 처리 (백업)
cron.schedule('*/10 * * * *', async () => {
try {
await processPendingQueue();
} catch (error) {
console.error('[Scheduler] 큐 처리 오류:', error);
}
});
console.log('[Scheduler] 스케줄러 등록 완료 - 매 시간 정각에 실행');
};
/**
* 수동으로 특정 사용자의 자동 생성 실행
*/
const triggerManualGeneration = async (userId) => {
return new Promise((resolve, reject) => {
db.get(`
SELECT ags.*, pp.id as pension_id
FROM auto_generation_settings ags
LEFT JOIN pension_profiles pp ON ags.pension_id = pp.id OR (pp.user_id = ags.user_id AND pp.is_default = 1)
WHERE ags.user_id = ?
`, [userId], async (err, settings) => {
if (err || !settings) {
return reject(new Error('자동 생성 설정을 찾을 수 없습니다.'));
}
const queueId = await addToQueue(userId, settings.pension_id);
if (!queueId) {
return reject(new Error('큐 추가 실패'));
}
// 즉시 처리
const result = await processPendingQueue();
resolve(result);
});
});
};
module.exports = {
startScheduler,
getPendingGenerationJobs,
processPendingQueue,
triggerManualGeneration
};

View File

@ -0,0 +1,439 @@
/**
* TourAPI 4.0 클라이언트
* 한국관광공사 국문 관광정보 서비스 (KorService2)
*
* 문서: https://www.data.go.kr/data/15101578/openapi.do
* Base URL: https://apis.data.go.kr/B551011/KorService2
*/
const axios = require('axios');
// 관광타입 ID
const CONTENT_TYPE = {
TOURIST_SPOT: '12', // 관광지
CULTURAL: '14', // 문화시설
FESTIVAL: '15', // 행사/공연/축제
TRAVEL_COURSE: '25', // 여행코스
LEISURE: '28', // 레포츠
STAY: '32', // 숙박 (펜션)
SHOPPING: '38', // 쇼핑
RESTAURANT: '39', // 음식점
};
// 지역코드
const AREA_CODE = {
SEOUL: '1',
INCHEON: '2',
DAEJEON: '3',
DAEGU: '4',
GWANGJU: '5',
BUSAN: '6',
ULSAN: '7',
SEJONG: '8',
GYEONGGI: '31',
GANGWON: '32',
CHUNGBUK: '33',
CHUNGNAM: '34',
GYEONGBUK: '35',
GYEONGNAM: '36',
JEONBUK: '37',
JEONNAM: '38',
JEJU: '39',
};
// 지역코드 이름 매핑
const AREA_NAME = {
'1': '서울',
'2': '인천',
'3': '대전',
'4': '대구',
'5': '광주',
'6': '부산',
'7': '울산',
'8': '세종',
'31': '경기',
'32': '강원',
'33': '충북',
'34': '충남',
'35': '경북',
'36': '경남',
'37': '전북',
'38': '전남',
'39': '제주',
};
class TourApiClient {
constructor() {
this.baseUrl = process.env.TOURAPI_ENDPOINT || 'https://apis.data.go.kr/B551011/KorService2';
this.serviceKey = process.env.TOURAPI_KEY;
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 30000,
});
}
/**
* API 요청 공통 메서드
*/
async request(operation, params = {}) {
try {
const response = await this.client.get(`/${operation}`, {
params: {
serviceKey: this.serviceKey,
MobileOS: 'ETC',
MobileApp: 'CastAD',
_type: 'json',
...params,
},
});
const data = response.data;
// 에러 체크
if (data.response?.header?.resultCode !== '0000') {
const errorMsg = data.response?.header?.resultMsg || 'Unknown error';
throw new Error(`TourAPI Error: ${errorMsg}`);
}
const body = data.response?.body;
return {
items: body?.items?.item || [],
totalCount: body?.totalCount || 0,
pageNo: body?.pageNo || 1,
numOfRows: body?.numOfRows || 10,
};
} catch (error) {
if (error.response?.status === 401) {
throw new Error('TourAPI 인증 실패: 서비스키를 확인하세요');
}
throw error;
}
}
// ============================================
// 지역/분류 코드 조회
// ============================================
/**
* 지역코드 조회
* @param {string} areaCode - 시도 코드 (없으면 전체 시도 조회)
*/
async getAreaCodes(areaCode = null) {
const params = { numOfRows: 100, pageNo: 1 };
if (areaCode) params.areaCode = areaCode;
return this.request('areaCode2', params);
}
/**
* 서비스 분류코드 조회
*/
async getCategoryCodes(contentTypeId = null, cat1 = null, cat2 = null) {
const params = { numOfRows: 100, pageNo: 1 };
if (contentTypeId) params.contentTypeId = contentTypeId;
if (cat1) params.cat1 = cat1;
if (cat2) params.cat2 = cat2;
return this.request('categoryCode2', params);
}
// ============================================
// 축제/행사 조회 (핵심!)
// ============================================
/**
* 행사정보 조회 (축제)
* @param {Object} options
* @param {string} options.eventStartDate - 행사 시작일 (YYYYMMDD) - 필수!
* @param {string} options.eventEndDate - 행사 종료일 (YYYYMMDD)
* @param {string} options.areaCode - 지역코드
* @param {string} options.sigunguCode - 시군구코드
* @param {number} options.numOfRows - 페이지 결과
* @param {number} options.pageNo - 페이지 번호
* @param {string} options.arrange - 정렬 (A=제목순, C=수정일순, D=생성일순)
*/
async searchFestival(options = {}) {
const {
eventStartDate = this.getTodayString(),
eventEndDate,
areaCode,
sigunguCode,
numOfRows = 100,
pageNo = 1,
arrange = 'A',
} = options;
const params = {
numOfRows,
pageNo,
arrange,
eventStartDate,
};
if (eventEndDate) params.eventEndDate = eventEndDate;
if (areaCode) params.areaCode = areaCode;
if (sigunguCode) params.sigunguCode = sigunguCode;
return this.request('searchFestival2', params);
}
/**
* 진행중인 축제 조회
*/
async getOngoingFestivals(areaCode = null) {
const today = this.getTodayString();
return this.searchFestival({
eventStartDate: '20240101', // 과거부터
eventEndDate: today, // 오늘까지 종료되지 않은
areaCode,
arrange: 'C', // 수정일순
});
}
/**
* 예정 축제 조회 (향후 6개월)
*/
async getUpcomingFestivals(areaCode = null, months = 6) {
const today = this.getTodayString();
const endDate = this.getDateAfterMonths(months);
return this.searchFestival({
eventStartDate: today,
eventEndDate: endDate,
areaCode,
arrange: 'A', // 제목순
});
}
// ============================================
// 숙박 조회 (펜션)
// ============================================
/**
* 숙박정보 조회
* @param {Object} options
* @param {string} options.areaCode - 지역코드
* @param {string} options.sigunguCode - 시군구코드
* @param {number} options.numOfRows - 페이지 결과
* @param {number} options.pageNo - 페이지 번호
* @param {string} options.arrange - 정렬
*/
async searchStay(options = {}) {
const {
areaCode,
sigunguCode,
numOfRows = 100,
pageNo = 1,
arrange = 'A',
} = options;
const params = { numOfRows, pageNo, arrange };
if (areaCode) params.areaCode = areaCode;
if (sigunguCode) params.sigunguCode = sigunguCode;
return this.request('searchStay2', params);
}
// ============================================
// 지역/위치 기반 조회
// ============================================
/**
* 지역기반 관광정보 조회
*/
async getAreaBasedList(options = {}) {
const {
contentTypeId,
areaCode,
sigunguCode,
cat1, cat2, cat3,
numOfRows = 100,
pageNo = 1,
arrange = 'C',
} = options;
const params = { numOfRows, pageNo, arrange };
if (contentTypeId) params.contentTypeId = contentTypeId;
if (areaCode) params.areaCode = areaCode;
if (sigunguCode) params.sigunguCode = sigunguCode;
if (cat1) params.cat1 = cat1;
if (cat2) params.cat2 = cat2;
if (cat3) params.cat3 = cat3;
return this.request('areaBasedList2', params);
}
/**
* 위치기반 관광정보 조회 (근처 검색)
* @param {number} mapX - 경도 (longitude)
* @param {number} mapY - 위도 (latitude)
* @param {number} radius - 반경 (미터, 최대 20000)
* @param {string} contentTypeId - 관광타입
*/
async getLocationBasedList(mapX, mapY, radius = 10000, contentTypeId = null) {
const params = {
mapX: mapX.toString(),
mapY: mapY.toString(),
radius: Math.min(radius, 20000), // 최대 20km
numOfRows: 100,
pageNo: 1,
arrange: 'E', // 거리순
};
if (contentTypeId) params.contentTypeId = contentTypeId;
return this.request('locationBasedList2', params);
}
/**
* 근처 축제 검색
*/
async getNearbyFestivals(mapX, mapY, radius = 20000) {
return this.getLocationBasedList(mapX, mapY, radius, CONTENT_TYPE.FESTIVAL);
}
/**
* 근처 숙박시설 검색
*/
async getNearbyStay(mapX, mapY, radius = 20000) {
return this.getLocationBasedList(mapX, mapY, radius, CONTENT_TYPE.STAY);
}
// ============================================
// 상세 정보 조회
// ============================================
/**
* 공통정보 조회 (상세정보1)
*/
async getDetailCommon(contentId) {
return this.request('detailCommon2', { contentId });
}
/**
* 소개정보 조회 (상세정보2) - 타입별 상세
*/
async getDetailIntro(contentId, contentTypeId) {
return this.request('detailIntro2', { contentId, contentTypeId });
}
/**
* 반복정보 조회 (상세정보3) - 객실정보
*/
async getDetailInfo(contentId, contentTypeId) {
return this.request('detailInfo2', { contentId, contentTypeId });
}
/**
* 이미지정보 조회 (상세정보4)
*/
async getDetailImage(contentId) {
return this.request('detailImage2', { contentId });
}
/**
* 축제 전체 상세정보 조회
*/
async getFestivalDetail(contentId) {
const [common, intro, images] = await Promise.all([
this.getDetailCommon(contentId),
this.getDetailIntro(contentId, CONTENT_TYPE.FESTIVAL),
this.getDetailImage(contentId),
]);
return {
common: common.items[0] || {},
intro: intro.items[0] || {},
images: images.items || [],
};
}
/**
* 숙박 전체 상세정보 조회
*/
async getStayDetail(contentId) {
const [common, intro, rooms, images] = await Promise.all([
this.getDetailCommon(contentId),
this.getDetailIntro(contentId, CONTENT_TYPE.STAY),
this.getDetailInfo(contentId, CONTENT_TYPE.STAY),
this.getDetailImage(contentId),
]);
return {
common: common.items[0] || {},
intro: intro.items[0] || {},
rooms: rooms.items || [],
images: images.items || [],
};
}
// ============================================
// 동기화
// ============================================
/**
* 관광정보 동기화 목록 조회 (변경된 데이터)
*/
async getSyncList(options = {}) {
const {
contentTypeId,
modifiedTime, // YYYYMMDD 형식
numOfRows = 100,
pageNo = 1,
} = options;
const params = { numOfRows, pageNo };
if (contentTypeId) params.contentTypeId = contentTypeId;
if (modifiedTime) params.modifiedtime = modifiedTime;
return this.request('areaBasedSyncList2', params);
}
// ============================================
// 유틸리티
// ============================================
/**
* 오늘 날짜 문자열 (YYYYMMDD)
*/
getTodayString() {
return new Date().toISOString().slice(0, 10).replace(/-/g, '');
}
/**
* N개월 날짜 문자열
*/
getDateAfterMonths(months) {
const date = new Date();
date.setMonth(date.getMonth() + months);
return date.toISOString().slice(0, 10).replace(/-/g, '');
}
/**
* 날짜 파싱 (YYYYMMDD -> Date)
*/
parseDate(dateStr) {
if (!dateStr || dateStr.length < 8) return null;
const year = dateStr.slice(0, 4);
const month = dateStr.slice(4, 6);
const day = dateStr.slice(6, 8);
return new Date(`${year}-${month}-${day}`);
}
/**
* 날짜 포맷팅 (YYYYMMDD -> YYYY.MM.DD)
*/
formatDate(dateStr) {
if (!dateStr || dateStr.length < 8) return '';
return `${dateStr.slice(0, 4)}.${dateStr.slice(4, 6)}.${dateStr.slice(6, 8)}`;
}
}
// 싱글톤 인스턴스
const tourApiClient = new TourApiClient();
module.exports = {
tourApiClient,
TourApiClient,
CONTENT_TYPE,
AREA_CODE,
AREA_NAME,
};

View File

@ -1,4 +1,4 @@
import { BusinessInfo, TTSConfig, AspectRatio, Language } from '../types'; import { BusinessInfo, TTSConfig, AspectRatio, Language, BusinessDNA } from '../types';
import { decodeBase64, decodeAudioData, bufferToWaveBlob } from './audioUtils'; import { decodeBase64, decodeAudioData, bufferToWaveBlob } from './audioUtils';
const SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']; const SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'];
@ -325,3 +325,56 @@ export const extractTextEffectFromImage = async (
throw new Error(e.message || "텍스트 스타일 분석에 실패했습니다."); throw new Error(e.message || "텍스트 스타일 분석에 실패했습니다.");
} }
}; };
/**
* DNA (Gemini 2.5 Flash + Google Search Grounding) -
* / DNA , , , ,
* @param {string} nameOrUrl - URL
* @param {File[]} images - ()
* @param {(progress: string) => void} onProgress -
* @returns {Promise<BusinessDNA>} - DNA
*/
export const analyzeBusinessDNA = async (
nameOrUrl: string,
images?: File[],
onProgress?: (progress: string) => void
): Promise<BusinessDNA> => {
try {
onProgress?.('Google 검색으로 정보 수집 중...');
// 이미지가 있으면 Base64로 변환
let imagesForBackend: { base64: string; mimeType: string }[] = [];
if (images && images.length > 0) {
onProgress?.('이미지 분석 준비 중...');
imagesForBackend = await Promise.all(
images.slice(0, 5).map(file => fileToBase64(file)) // 최대 5장만
);
}
onProgress?.('AI가 브랜드 DNA를 분석하고 있습니다...');
const response = await fetch('/api/gemini/analyze-dna', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
nameOrUrl,
images: imagesForBackend
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
onProgress?.('DNA 분석 완료!');
return data.dna as BusinessDNA;
} catch (e: any) {
console.error("Analyze Business DNA (Frontend) Failed:", e);
throw new Error(e.message || "비즈니스 DNA 분석에 실패했습니다.");
}
};

View File

@ -58,7 +58,7 @@ export const crawlGooglePlace = async (
onProgress?: (msg: string) => void, onProgress?: (msg: string) => void,
options?: CrawlOptions options?: CrawlOptions
): Promise<Partial<BusinessInfo>> => { ): Promise<Partial<BusinessInfo>> => {
const maxImages = options?.maxImages ?? 15; const maxImages = options?.maxImages ?? 100;
const existingFingerprints = options?.existingFingerprints ?? new Set<string>(); const existingFingerprints = options?.existingFingerprints ?? new Set<string>();
onProgress?.("Google 지도 정보 가져오는 중..."); onProgress?.("Google 지도 정보 가져오는 중...");

View File

@ -0,0 +1,160 @@
import { BusinessInfo } from '../types';
/**
* : URL File .
* CORS .
*/
const urlToFile = async (url: string, filename: string): Promise<File> => {
try {
const proxyUrl = `/api/proxy/image?url=${encodeURIComponent(url)}`;
const response = await fetch(proxyUrl);
if (!response.ok) {
throw new Error(`Proxy failed with status: ${response.status}`);
}
const blob = await response.blob();
return new File([blob], filename, { type: blob.type || 'image/jpeg' });
} catch (e) {
console.error(`이미지 다운로드 실패 ${url}:`, e);
throw new Error(`이미지 다운로드 실패: ${url}`);
}
};
/**
* ( + ).
*/
const getImageFingerprint = async (file: File): Promise<string> => {
const size = file.size;
const slice = file.slice(0, 1024);
const buffer = await slice.arrayBuffer();
const bytes = new Uint8Array(buffer);
let sum = 0;
for (let i = 0; i < bytes.length; i++) {
sum = (sum + bytes[i]) % 65536;
}
return `${size}-${sum}`;
};
/**
* fingerprint .
*/
export const getExistingFingerprints = async (existingImages: File[]): Promise<Set<string>> => {
const fingerprints = new Set<string>();
for (const img of existingImages) {
try {
const fp = await getImageFingerprint(img);
fingerprints.add(fp);
} catch (e) {
console.warn('Fingerprint 생성 실패:', e);
}
}
return fingerprints;
};
export interface CrawlOptions {
maxImages?: number;
existingFingerprints?: Set<string>;
}
/**
* Instagram URL username .
*/
export const parseInstagramUrl = (url: string): string | null => {
try {
// instagram.com/username 형식
const match = url.match(/instagram\.com\/([a-zA-Z0-9._]+)/);
if (match && match[1] && !['p', 'reel', 'stories', 'explore', 'accounts'].includes(match[1])) {
return match[1];
}
return null;
} catch {
return null;
}
};
/**
* .
* API .
* @param {string} url - URL
* @param {(msg: string) => void} [onProgress] -
* @param {CrawlOptions} [options] - (maxImages, existingFingerprints)
* @returns {Promise<Partial<BusinessInfo>>} -
*/
export const crawlInstagramProfile = async (
url: string,
onProgress?: (msg: string) => void,
options?: CrawlOptions
): Promise<Partial<BusinessInfo>> => {
const maxImages = options?.maxImages ?? 100;
const existingFingerprints = options?.existingFingerprints ?? new Set<string>();
onProgress?.("인스타그램 프로필 정보 가져오는 중 (서버 요청)...");
try {
// 백엔드 Express 서버의 /api/instagram/crawl 엔드포인트 호출
const response = await fetch('/api/instagram/crawl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.error || "서버 크롤링 요청 실패");
}
const data = await response.json();
console.log(`백엔드 크롤링 결과: ${data.name}, 총 ${data.totalImages || data.images?.length}장 이미지 (셔플됨)`);
onProgress?.(`'${data.name}' 정보 수신 완료. 이미지 다운로드 중... (총 ${data.totalImages || '?'}장 중 최대 ${maxImages}장)`);
// 크롤링된 이미지 URL들을 File 객체로 변환 (중복 검사 포함)
const imageFiles: File[] = [];
const imageUrls = data.images || [];
let skippedDuplicates = 0;
for (let i = 0; i < imageUrls.length && imageFiles.length < maxImages; i++) {
const imgUrl = imageUrls[i];
try {
onProgress?.(`이미지 다운로드 중 (${imageFiles.length + 1}/${maxImages})...`);
const file = await urlToFile(imgUrl, `instagram_${data.username}_${i}.jpg`);
// 중복 검사
const fingerprint = await getImageFingerprint(file);
if (existingFingerprints.has(fingerprint)) {
console.log(`중복 이미지 발견, 건너뜁니다: ${imgUrl.slice(-30)}`);
skippedDuplicates++;
continue;
}
imageFiles.push(file);
} catch (e) {
console.warn("이미지 다운로드 실패, 건너뜁니다:", imgUrl, e);
}
}
if (skippedDuplicates > 0) {
console.log(`${skippedDuplicates}장의 중복 이미지를 건너뛰었습니다.`);
}
if (imageFiles.length === 0) {
throw new Error("유효한 이미지를 하나도 다운로드하지 못했습니다. 다른 URL을 시도해보세요.");
}
onProgress?.(`${imageFiles.length}장의 이미지를 가져왔습니다.`);
return {
name: data.name,
description: data.description || `${data.name} - Instagram (@${data.username})`,
images: imageFiles,
address: '',
category: 'Instagram',
sourceUrl: url
};
} catch (error: any) {
console.error("인스타그램 크롤링 실패:", error);
throw new Error(error.message || "인스타그램 프로필 정보를 가져오는데 실패했습니다.");
}
};

View File

@ -58,7 +58,7 @@ export const getExistingFingerprints = async (existingImages: File[]): Promise<S
}; };
export interface CrawlOptions { export interface CrawlOptions {
maxImages?: number; // 가져올 최대 이미지 수 (기본값: 15) maxImages?: number; // 가져올 최대 이미지 수 (기본값: 100)
existingFingerprints?: Set<string>; // 중복 검사용 기존 이미지 fingerprints existingFingerprints?: Set<string>; // 중복 검사용 기존 이미지 fingerprints
} }
@ -75,7 +75,7 @@ export const crawlNaverPlace = async (
onProgress?: (msg: string) => void, onProgress?: (msg: string) => void,
options?: CrawlOptions options?: CrawlOptions
): Promise<Partial<BusinessInfo>> => { ): Promise<Partial<BusinessInfo>> => {
const maxImages = options?.maxImages ?? 15; const maxImages = options?.maxImages ?? 100;
const existingFingerprints = options?.existingFingerprints ?? new Set<string>(); const existingFingerprints = options?.existingFingerprints ?? new Set<string>();
onProgress?.("네이버 플레이스 정보 가져오는 중 (서버 요청)..."); onProgress?.("네이버 플레이스 정보 가져오는 중 (서버 요청)...");

196
setup-nginx-castad1.sh Executable file
View File

@ -0,0 +1,196 @@
#!/bin/bash
#
# CaStAD1 Nginx 설정 스크립트
# 도메인: castad1.ktenterprise.net
# 포트: 3001 (백엔드), 5003 (Instagram)
#
# 색상
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[Setup]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
# root 권한 확인
if [ "$EUID" -ne 0 ]; then
error "이 스크립트는 sudo로 실행해야 합니다"
echo "사용법: sudo ./setup-nginx-castad1.sh"
exit 1
fi
log "CaStAD1 Nginx 설정을 시작합니다..."
# nginx 설정 파일 생성
NGINX_CONF="/etc/nginx/sites-available/castad1"
log "nginx 설정 파일 생성 중: $NGINX_CONF"
cat > "$NGINX_CONF" << 'EOF'
# ============================================
# CaStAD1 Nginx Configuration
# Domain: castad1.ktenterprise.net
# Port: 3001 (backend), 5003 (instagram)
# ============================================
upstream castad1_backend {
server 127.0.0.1:3001;
keepalive 64;
}
upstream castad1_instagram {
server 127.0.0.1:5003;
keepalive 32;
}
# HTTP → HTTPS 리다이렉트
server {
listen 80;
listen [::]:80;
server_name castad1.ktenterprise.net;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS 서버
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name castad1.ktenterprise.net;
# SSL 인증서
ssl_certificate /etc/letsencrypt/live/castad1.ktenterprise.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/castad1.ktenterprise.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_session_cache shared:SSL:10m;
# 보안 헤더
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# 로그
access_log /var/log/nginx/castad1_access.log;
error_log /var/log/nginx/castad1_error.log;
# 파일 업로드 크기
client_max_body_size 500M;
client_body_timeout 300s;
# 정적 파일 (프론트엔드) - castad1 디렉토리
root /home/developer/castad1/dist;
index index.html;
# Gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml;
# 정적 자원 캐싱
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# API 요청
location /api/ {
proxy_pass http://castad1_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
}
# Instagram API
location /instagram/ {
proxy_pass http://castad1_instagram/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s;
}
# 렌더링 요청 (긴 타임아웃)
location /render {
proxy_pass http://castad1_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
proxy_buffering off;
}
# 다운로드/업로드/임시 파일
location ~ ^/(downloads|temp|uploads)/ {
proxy_pass http://castad1_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# SPA 라우팅
location / {
try_files $uri $uri/ /index.html;
}
}
EOF
log "nginx 설정 파일 생성 완료"
# 심볼릭 링크 생성
LINK_PATH="/etc/nginx/sites-enabled/castad1"
if [ -L "$LINK_PATH" ]; then
warn "심볼릭 링크가 이미 존재합니다. 재생성합니다..."
rm "$LINK_PATH"
fi
ln -s "$NGINX_CONF" "$LINK_PATH"
log "심볼릭 링크 생성 완료"
# nginx 설정 테스트
log "nginx 설정 테스트 중..."
if nginx -t; then
log "nginx 설정 테스트 통과"
else
error "nginx 설정 테스트 실패!"
exit 1
fi
# nginx 재시작
log "nginx 재시작 중..."
systemctl reload nginx
log "nginx 재시작 완료"
echo ""
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ CaStAD1 Nginx 설정 완료! ║${NC}"
echo -e "${GREEN}╠════════════════════════════════════════════════════════════╣${NC}"
echo -e "${GREEN}║ 도메인: https://castad1.ktenterprise.net ║${NC}"
echo -e "${GREEN}║ 백엔드: 127.0.0.1:3001 ║${NC}"
echo -e "${GREEN}║ Instagram: 127.0.0.1:5003 ║${NC}"
echo -e "${GREEN}║ 정적파일: /home/developer/castad1/dist ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
echo ""

296
src/components/DNACard.tsx Normal file
View File

@ -0,0 +1,296 @@
import React from 'react';
import { BusinessDNA } from '../../types';
import { Badge } from './ui/badge';
import {
Palette,
Users,
Sparkles,
Eye,
Target,
Hash,
Star,
TrendingUp,
ExternalLink,
Camera,
Home,
Heart
} from 'lucide-react';
import { cn } from '../lib/utils';
interface DNACardProps {
dna: BusinessDNA;
className?: string;
compact?: boolean;
}
const DNACard: React.FC<DNACardProps> = ({ dna, className, compact = false }) => {
if (compact) {
return (
<div className={cn("bg-card rounded-xl border border-border p-4", className)}>
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: dna.brandColors.primary + '20' }}
>
<Sparkles className="w-5 h-5" style={{ color: dna.brandColors.primary }} />
</div>
<div>
<h3 className="font-semibold">{dna.name}</h3>
{dna.tagline && (
<p className="text-xs text-muted-foreground">{dna.tagline}</p>
)}
</div>
</div>
{/* Color Palette (mini) */}
<div className="flex gap-1 mb-3">
{dna.brandColors.palette?.slice(0, 5).map((color, idx) => (
<div
key={idx}
className="w-6 h-6 rounded-md border border-border/50"
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
{/* Keywords (mini) */}
<div className="flex flex-wrap gap-1">
{dna.keywords.primary.slice(0, 4).map((keyword, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
{keyword}
</Badge>
))}
</div>
</div>
);
}
return (
<div className={cn("bg-card rounded-2xl border border-border overflow-hidden", className)}>
{/* Header with gradient */}
<div
className="p-6 text-white"
style={{
background: `linear-gradient(135deg, ${dna.brandColors.primary}, ${dna.brandColors.secondary || dna.brandColors.primary})`
}}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5" />
<span className="text-sm font-medium opacity-90">Business DNA</span>
</div>
{dna.confidence && (
<Badge variant="secondary" className="bg-white/20 text-white border-0">
{Math.round(dna.confidence * 100)}%
</Badge>
)}
</div>
<h2 className="text-2xl font-bold">{dna.name}</h2>
{dna.tagline && (
<p className="text-white/80 mt-1">{dna.tagline}</p>
)}
</div>
<div className="p-6 space-y-6">
{/* Tone & Manner */}
<div>
<div className="flex items-center gap-2 mb-3">
<Heart className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-sm"> & </h3>
</div>
<div className="flex flex-wrap gap-2 mb-2">
<Badge>{dna.toneAndManner.primary}</Badge>
{dna.toneAndManner.secondary && (
<Badge variant="outline">{dna.toneAndManner.secondary}</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{dna.toneAndManner.description}</p>
</div>
{/* Target Customers */}
<div>
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-sm"> </h3>
</div>
<div className="flex flex-wrap gap-2 mb-2">
<Badge variant="default">{dna.targetCustomers.primary}</Badge>
{dna.targetCustomers.secondary?.map((target, idx) => (
<Badge key={idx} variant="outline">{target}</Badge>
))}
</div>
{dna.targetCustomers.ageRange && (
<p className="text-xs text-muted-foreground mb-1">
: {dna.targetCustomers.ageRange}
</p>
)}
{dna.targetCustomers.characteristics && (
<div className="flex flex-wrap gap-1 mt-2">
{dna.targetCustomers.characteristics.map((char, idx) => (
<span key={idx} className="text-xs bg-muted px-2 py-0.5 rounded">
{char}
</span>
))}
</div>
)}
</div>
{/* Brand Colors */}
<div>
<div className="flex items-center gap-2 mb-3">
<Palette className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-sm"> </h3>
</div>
<div className="flex gap-2 mb-2">
{dna.brandColors.palette?.map((color, idx) => (
<div key={idx} className="flex flex-col items-center">
<div
className="w-10 h-10 rounded-lg border border-border shadow-sm"
style={{ backgroundColor: color }}
/>
<span className="text-[10px] text-muted-foreground mt-1">{color}</span>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
{dna.brandColors.mood}
</p>
</div>
{/* Visual Style */}
<div>
<div className="flex items-center gap-2 mb-3">
<Camera className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-sm"> </h3>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center gap-1 mb-1">
<Home className="w-3 h-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground"></span>
</div>
<p className="text-sm font-medium">{dna.visualStyle.interior}</p>
</div>
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center gap-1 mb-1">
<Eye className="w-3 h-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground"></span>
</div>
<p className="text-sm font-medium">{dna.visualStyle.exterior}</p>
</div>
</div>
<div className="mt-3 p-3 bg-muted/30 rounded-lg">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-sm">{dna.visualStyle.atmosphere}</p>
<p className="text-xs text-muted-foreground mt-2"> : {dna.visualStyle.photoStyle}</p>
{dna.visualStyle.suggestedFilters && (
<div className="flex gap-1 mt-2">
{dna.visualStyle.suggestedFilters.map((filter, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{filter}
</Badge>
))}
</div>
)}
</div>
</div>
{/* Keywords & Hashtags */}
<div>
<div className="flex items-center gap-2 mb-3">
<Hash className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-sm"> & </h3>
</div>
<div className="flex flex-wrap gap-1 mb-2">
{dna.keywords.primary.map((keyword, idx) => (
<Badge key={idx} className="bg-primary/10 text-primary border-0">
{keyword}
</Badge>
))}
</div>
{dna.keywords.secondary && (
<div className="flex flex-wrap gap-1 mb-3">
{dna.keywords.secondary.map((keyword, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{keyword}
</Badge>
))}
</div>
)}
{dna.keywords.hashtags && (
<div className="flex flex-wrap gap-1">
{dna.keywords.hashtags.map((tag, idx) => (
<span key={idx} className="text-xs text-primary">
{tag}
</span>
))}
</div>
)}
</div>
{/* Unique Selling Points */}
<div>
<div className="flex items-center gap-2 mb-3">
<Star className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-sm"> </h3>
</div>
<ul className="space-y-2">
{dna.uniqueSellingPoints.map((usp, idx) => (
<li key={idx} className="flex items-start gap-2 text-sm">
<TrendingUp className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
{usp}
</li>
))}
</ul>
</div>
{/* Mood */}
<div>
<div className="flex items-center gap-2 mb-3">
<Target className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-sm"> & </h3>
</div>
<Badge className="mb-2">{dna.mood.primary}</Badge>
<div className="flex flex-wrap gap-1">
{dna.mood.emotions.map((emotion, idx) => (
<span key={idx} className="text-xs bg-muted px-2 py-1 rounded-full">
{emotion}
</span>
))}
</div>
</div>
{/* Sources */}
{dna.sources && dna.sources.length > 0 && (
<div className="pt-4 border-t border-border">
<p className="text-xs text-muted-foreground mb-2"> </p>
<div className="flex flex-wrap gap-2">
{dna.sources.map((source, idx) => (
<a
key={idx}
href={source}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
<ExternalLink className="w-3 h-3" />
{new URL(source).hostname}
</a>
))}
</div>
</div>
)}
{/* Analyzed At */}
{dna.analyzedAt && (
<p className="text-xs text-muted-foreground text-right">
: {new Date(dna.analyzedAt).toLocaleString('ko-KR')}
</p>
)}
</div>
</div>
);
};
export default DNACard;

194
src/components/KoreaMap.tsx Normal file
View File

@ -0,0 +1,194 @@
import React, { useState } from 'react';
import { cn } from '../lib/utils';
interface RegionData {
sido: string;
count: number;
}
interface KoreaMapProps {
data: RegionData[];
onRegionClick?: (sido: string) => void;
className?: string;
}
// 지역별 색상 (축제 수에 따라 그라데이션)
const getRegionColor = (count: number, maxCount: number) => {
if (count === 0) return '#e5e7eb'; // gray-200
const intensity = Math.min(count / Math.max(maxCount, 1), 1);
// Orange gradient from light to dark
if (intensity < 0.2) return '#fed7aa'; // orange-200
if (intensity < 0.4) return '#fdba74'; // orange-300
if (intensity < 0.6) return '#fb923c'; // orange-400
if (intensity < 0.8) return '#f97316'; // orange-500
return '#ea580c'; // orange-600
};
// 한국 지도 SVG 경로 데이터 (간략화된 버전)
const REGION_PATHS: Record<string, { path: string; labelX: number; labelY: number }> = {
'서울': {
path: 'M 145 95 L 155 90 L 165 95 L 165 105 L 155 110 L 145 105 Z',
labelX: 155, labelY: 100
},
'인천': {
path: 'M 120 90 L 140 85 L 145 95 L 145 110 L 135 115 L 120 105 Z',
labelX: 132, labelY: 100
},
'경기': {
path: 'M 120 70 L 180 60 L 195 80 L 190 120 L 165 130 L 135 125 L 110 110 L 115 85 Z',
labelX: 155, labelY: 85
},
'강원': {
path: 'M 180 45 L 250 30 L 280 60 L 270 120 L 220 140 L 190 120 L 195 80 Z',
labelX: 230, labelY: 85
},
'충북': {
path: 'M 165 130 L 220 140 L 230 170 L 200 195 L 165 185 L 155 155 Z',
labelX: 190, labelY: 165
},
'충남': {
path: 'M 90 130 L 155 125 L 165 155 L 155 190 L 120 210 L 80 190 L 70 155 Z',
labelX: 115, labelY: 170
},
'세종': {
path: 'M 145 155 L 165 155 L 165 175 L 145 175 Z',
labelX: 155, labelY: 165
},
'대전': {
path: 'M 155 175 L 175 175 L 175 195 L 155 195 Z',
labelX: 165, labelY: 185
},
'전북': {
path: 'M 80 190 L 155 190 L 165 185 L 175 210 L 155 250 L 100 260 L 70 230 Z',
labelX: 120, labelY: 225
},
'전남': {
path: 'M 70 230 L 100 260 L 155 250 L 170 280 L 150 320 L 90 330 L 50 300 L 45 260 Z',
labelX: 105, labelY: 290
},
'광주': {
path: 'M 95 270 L 115 265 L 120 285 L 100 290 Z',
labelX: 107, labelY: 278
},
'경북': {
path: 'M 200 140 L 270 120 L 290 160 L 280 220 L 240 240 L 200 230 L 175 210 L 175 175 L 200 170 Z',
labelX: 235, labelY: 185
},
'대구': {
path: 'M 225 210 L 250 205 L 255 225 L 230 230 Z',
labelX: 240, labelY: 218
},
'울산': {
path: 'M 275 220 L 295 210 L 300 235 L 280 245 Z',
labelX: 287, labelY: 228
},
'부산': {
path: 'M 260 265 L 290 255 L 300 280 L 275 295 L 255 285 Z',
labelX: 275, labelY: 275
},
'경남': {
path: 'M 155 250 L 200 230 L 240 240 L 260 265 L 255 285 L 220 310 L 170 300 L 150 280 Z',
labelX: 200, labelY: 270
},
'제주': {
path: 'M 80 380 L 150 375 L 160 400 L 140 420 L 90 420 L 70 400 Z',
labelX: 115, labelY: 400
}
};
const KoreaMap: React.FC<KoreaMapProps> = ({ data, onRegionClick, className }) => {
const [hoveredRegion, setHoveredRegion] = useState<string | null>(null);
// 데이터를 지역명으로 매핑
const dataMap = new Map(data.map(d => [d.sido, d.count]));
const maxCount = Math.max(...data.map(d => d.count), 1);
const getCount = (sido: string) => dataMap.get(sido) || 0;
return (
<div className={cn("relative", className)}>
<svg
viewBox="0 0 350 450"
className="w-full h-auto"
style={{ maxHeight: '400px' }}
>
{/* 배경 */}
<rect x="0" y="0" width="350" height="450" fill="transparent" />
{/* 지역별 경로 */}
{Object.entries(REGION_PATHS).map(([sido, { path, labelX, labelY }]) => {
const count = getCount(sido);
const isHovered = hoveredRegion === sido;
return (
<g key={sido}>
{/* 지역 영역 */}
<path
d={path}
fill={getRegionColor(count, maxCount)}
stroke={isHovered ? '#ea580c' : '#9ca3af'}
strokeWidth={isHovered ? 2 : 1}
className="cursor-pointer transition-all duration-200"
style={{
filter: isHovered ? 'brightness(1.1)' : 'none',
transform: isHovered ? 'scale(1.02)' : 'scale(1)',
transformOrigin: `${labelX}px ${labelY}px`
}}
onMouseEnter={() => setHoveredRegion(sido)}
onMouseLeave={() => setHoveredRegion(null)}
onClick={() => onRegionClick?.(sido)}
/>
{/* 지역명 & 축제 수 */}
<text
x={labelX}
y={labelY - 5}
textAnchor="middle"
className="text-[10px] font-bold fill-gray-700 pointer-events-none select-none"
>
{sido}
</text>
{count > 0 && (
<text
x={labelX}
y={labelY + 8}
textAnchor="middle"
className="text-[9px] font-medium fill-orange-700 pointer-events-none select-none"
>
{count}
</text>
)}
</g>
);
})}
</svg>
{/* 호버 툴팁 */}
{hoveredRegion && (
<div className="absolute top-2 right-2 bg-white dark:bg-gray-800 shadow-lg rounded-lg p-3 border z-10">
<p className="font-bold text-sm">{hoveredRegion}</p>
<p className="text-orange-500 font-medium">
{getCount(hoveredRegion)}
</p>
</div>
)}
{/* 범례 */}
<div className="mt-4 flex items-center justify-center gap-2 text-xs text-muted-foreground">
<span></span>
<div className="flex">
{['#e5e7eb', '#fed7aa', '#fdba74', '#fb923c', '#f97316', '#ea580c'].map((color, i) => (
<div
key={i}
className="w-6 h-3"
style={{ backgroundColor: color }}
/>
))}
</div>
<span></span>
</div>
</div>
);
};
export default KoreaMap;

View File

@ -0,0 +1,415 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
import { Badge } from './ui/badge';
import {
Building,
MapPin,
FileText,
Loader2,
CheckCircle,
AlertCircle,
Link2,
Image as ImageIcon,
Sparkles
} from 'lucide-react';
import { crawlNaverPlace } from '../../services/naverService';
import { crawlGooglePlace } from '../../services/googlePlacesService';
import { crawlInstagramProfile } from '../../services/instagramService';
import { fileToBase64 } from '../../services/geminiService';
const PENSION_ONBOARDING_KEY = 'castad-pension-onboarding-shown';
interface PensionOnboardingDialogProps {
onComplete?: () => void;
}
const PensionOnboardingDialog: React.FC<PensionOnboardingDialogProps> = ({ onComplete }) => {
const { token, user } = useAuth();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState({
name: '',
address: '',
description: '',
sourceUrl: ''
});
// Crawling state
const [isFetching, setIsFetching] = useState(false);
const [fetchProgress, setFetchProgress] = useState('');
const [crawledImages, setCrawledImages] = useState<File[]>([]);
// Check if user has pensions on mount
useEffect(() => {
const checkPensions = async () => {
if (!token) {
setLoading(false);
return;
}
try {
const response = await fetch('/api/profile/pensions', {
headers: { Authorization: `Bearer ${token}` }
});
if (response.ok) {
const pensions = await response.json();
// If no pensions, show dialog
if (pensions.length === 0) {
// Small delay for better UX
setTimeout(() => {
setOpen(true);
}, 800);
}
}
} catch (e) {
console.error('Failed to check pensions:', e);
} finally {
setLoading(false);
}
};
checkPensions();
}, [token]);
// Detect URL type
const detectUrlType = (url: string): 'naver' | 'google' | 'instagram' | 'unknown' => {
const lowerUrl = url.toLowerCase();
if (lowerUrl.includes('naver.me') || lowerUrl.includes('naver.com') || lowerUrl.includes('map.naver')) {
return 'naver';
}
if (lowerUrl.includes('google.com/maps') || lowerUrl.includes('maps.google') || lowerUrl.includes('goo.gl/maps') || lowerUrl.includes('maps.app.goo.gl')) {
return 'google';
}
if (lowerUrl.includes('instagram.com') || lowerUrl.includes('instagr.am')) {
return 'instagram';
}
return 'unknown';
};
// Fetch place info from URL
const handleFetchUrl = async () => {
if (!formData.sourceUrl.trim()) {
setError('URL을 입력해주세요.');
return;
}
const urlType = detectUrlType(formData.sourceUrl);
if (urlType === 'unknown') {
setError('네이버 플레이스, 구글 지도 또는 인스타그램 URL을 입력해주세요.');
return;
}
setIsFetching(true);
setError('');
const progressMessages: Record<string, string> = {
naver: '네이버 플레이스 정보 가져오는 중...',
google: '구글 지도 정보 가져오는 중...',
instagram: '인스타그램 프로필 정보 가져오는 중...'
};
setFetchProgress(progressMessages[urlType]);
try {
const crawlOptions = { maxImages: 100 };
let placeData;
if (urlType === 'naver') {
placeData = await crawlNaverPlace(formData.sourceUrl, (msg) => setFetchProgress(msg), crawlOptions);
} else if (urlType === 'google') {
placeData = await crawlGooglePlace(formData.sourceUrl, (msg) => setFetchProgress(msg), crawlOptions);
} else {
placeData = await crawlInstagramProfile(formData.sourceUrl, (msg) => setFetchProgress(msg), crawlOptions);
}
// Update form with fetched data
setFormData(prev => ({
...prev,
name: placeData.name || prev.name,
address: placeData.address || prev.address,
description: placeData.description || prev.description
}));
// Store crawled images
if (placeData.images && placeData.images.length > 0) {
setCrawledImages(placeData.images);
}
setFetchProgress(`${placeData.name} 정보를 가져왔습니다! (사진 ${placeData.images?.length || 0}장)`);
} catch (err: any) {
console.error('Fetch failed:', err);
setError(err.message || '정보를 가져오는데 실패했습니다.');
setFetchProgress('');
} finally {
setIsFetching(false);
}
};
// Save crawled images to pension
const saveCrawledImagesToPension = async (pensionId: number) => {
if (crawledImages.length === 0) return;
try {
// Convert files to base64, detect source from filename
const imagesData = await Promise.all(crawledImages.map(async (file) => {
const { base64, mimeType } = await fileToBase64(file);
// Detect source from filename (naver_, google_, instagram_)
let source = 'crawl';
if (file.name.startsWith('naver_')) source = 'naver';
else if (file.name.startsWith('google_')) source = 'google';
else if (file.name.startsWith('instagram_')) source = 'instagram';
return { base64, mimeType, filename: file.name, source };
}));
await fetch(`/api/profile/pension/${pensionId}/images`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ images: imagesData })
});
console.log(`펜션 이미지 ${imagesData.length}장 저장 완료`);
} catch (error) {
console.error('펜션 이미지 저장 실패:', error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!formData.name.trim()) {
setError('펜션 이름을 입력해주세요.');
return;
}
setSubmitting(true);
try {
const response = await fetch('/api/profile/pensions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
brand_name: formData.name,
brand_name_en: formData.name,
address: formData.address,
description: formData.description,
pension_types: ['Family'],
target_customers: [],
key_features: [],
is_default: 1
})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || '펜션 등록에 실패했습니다.');
}
const pensionData = await response.json();
// Save crawled images if any
if (crawledImages.length > 0 && pensionData.pension_id) {
setFetchProgress('이미지 저장 중...');
await saveCrawledImagesToPension(pensionData.pension_id);
}
// Success - close dialog
localStorage.setItem(PENSION_ONBOARDING_KEY, 'true');
setOpen(false);
onComplete?.();
} catch (err: any) {
setError(err.message);
} finally {
setSubmitting(false);
setFetchProgress('');
}
};
const handleSkip = () => {
localStorage.setItem(PENSION_ONBOARDING_KEY, 'skipped');
setOpen(false);
};
if (loading) return null;
return (
<Dialog open={open} onOpenChange={(isOpen) => {
// Prevent closing by clicking outside
if (!isOpen && !submitting) {
// Allow closing only via skip button
}
}}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto" onPointerDownOutside={(e) => e.preventDefault()}>
<DialogHeader className="text-center pb-4">
{/* Icon */}
<div className="w-16 h-16 mx-auto rounded-2xl bg-gradient-to-br from-primary/20 to-accent/20 flex items-center justify-center mb-4">
<Building className="w-8 h-8 text-primary" />
</div>
<DialogTitle className="text-xl font-bold">
</DialogTitle>
<DialogDescription className="text-base mt-2">
.
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{/* URL Input - Optional */}
<div className="space-y-2 p-4 bg-muted/30 rounded-xl border border-dashed border-border">
<Label htmlFor="source-url" className="flex items-center gap-2 text-primary">
<Sparkles className="w-4 h-4" />
()
</Label>
<p className="text-xs text-muted-foreground mb-2">
, URL .
</p>
<div className="flex gap-2">
<Input
id="source-url"
value={formData.sourceUrl}
onChange={(e) => setFormData({ ...formData, sourceUrl: e.target.value })}
placeholder="https://naver.me/... 또는 https://instagram.com/username"
disabled={submitting || isFetching}
className="flex-1"
/>
<Button
type="button"
variant="secondary"
onClick={handleFetchUrl}
disabled={submitting || isFetching || !formData.sourceUrl.trim()}
>
{isFetching ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Link2 className="w-4 h-4" />
)}
</Button>
</div>
{fetchProgress && (
<div className="flex items-center gap-2 text-xs text-primary mt-2">
{isFetching && <Loader2 className="w-3 h-3 animate-spin" />}
{fetchProgress}
</div>
)}
{crawledImages.length > 0 && (
<div className="flex items-center gap-2 text-xs text-green-600 mt-2">
<ImageIcon className="w-3 h-3" />
{crawledImages.length}
</div>
)}
</div>
{/* Pension Name */}
<div className="space-y-2">
<Label htmlFor="pension-name" className="flex items-center gap-2">
<Building className="w-4 h-4" />
<span className="text-destructive">*</span>
</Label>
<Input
id="pension-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="예: 행복한 펜션"
disabled={submitting || isFetching}
autoFocus
/>
</div>
{/* Address */}
<div className="space-y-2">
<Label htmlFor="pension-address" className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
</Label>
<Input
id="pension-address"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="예: 강원도 강릉시 사천면"
disabled={submitting || isFetching}
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="pension-description" className="flex items-center gap-2">
<FileText className="w-4 h-4" />
()
</Label>
<Textarea
id="pension-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="펜션의 특징이나 매력 포인트를 간단히 작성해주세요"
rows={3}
disabled={submitting || isFetching}
/>
</div>
<DialogFooter className="flex-col sm:flex-row gap-2 pt-4">
<Button
type="button"
variant="ghost"
onClick={handleSkip}
disabled={submitting || isFetching}
>
</Button>
<Button type="submit" disabled={submitting || isFetching || !formData.name.trim()}>
{submitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<CheckCircle className="w-4 h-4 mr-2" />
{crawledImages.length > 0 && (
<Badge variant="secondary" className="ml-2">
+{crawledImages.length}
</Badge>
)}
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default PensionOnboardingDialog;

View File

@ -11,17 +11,20 @@ import {
Sparkles, Sparkles,
CreditCard, CreditCard,
Settings, Settings,
Building2 Building2,
PartyPopper
} from 'lucide-react'; } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { useLanguage } from '../../contexts/LanguageContext'; import { useLanguage } from '../../contexts/LanguageContext';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { useUserLevel, LEVEL_INFO } from '../../contexts/UserLevelContext';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Switch } from '../ui/switch'; import { Switch } from '../ui/switch';
import { Progress } from '../ui/progress'; import { Progress } from '../ui/progress';
import { Badge } from '../ui/badge';
export type ViewType = 'dashboard' | 'new-project' | 'library' | 'assets' | 'pensions' | 'account' | 'settings'; export type ViewType = 'dashboard' | 'new-project' | 'library' | 'assets' | 'pensions' | 'festivals' | 'account' | 'settings';
interface SidebarProps { interface SidebarProps {
currentView: ViewType; currentView: ViewType;
@ -63,6 +66,8 @@ const Sidebar: React.FC<SidebarProps> = ({
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const { t } = useLanguage(); const { t } = useLanguage();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { level, features } = useUserLevel();
const levelInfo = LEVEL_INFO[level];
// Mock credits - replace with actual user data // Mock credits - replace with actual user data
const credits = 850; const credits = 850;
@ -77,54 +82,81 @@ const Sidebar: React.FC<SidebarProps> = ({
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center"> <div className="w-9 h-9 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center">
<Sparkles className="w-5 h-5 text-white" /> <Sparkles className="w-5 h-5 text-white" />
</div> </div>
<span className="text-lg font-bold text-foreground">CastAD Pro</span> <div className="flex flex-col">
<span className="text-lg font-bold text-foreground">CastAD</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 w-fit">
{levelInfo.icon} {levelInfo.name}
</Badge>
</div>
</div> </div>
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 px-3 py-2 space-y-1"> <nav className="flex-1 px-3 py-2 space-y-1">
{/* 항상 표시: 대시보드 */}
<NavItem <NavItem
icon={<LayoutDashboard className="w-5 h-5" />} icon={<LayoutDashboard className="w-5 h-5" />}
label={user?.name || user?.username || t('dashWelcome').replace('!', '')} label={user?.name || user?.username || t('dashWelcome').replace('!', '')}
active={currentView === 'dashboard'} active={currentView === 'dashboard'}
onClick={() => onViewChange('dashboard')} onClick={() => onViewChange('dashboard')}
/> />
{/* 항상 표시: 새 영상 만들기 */}
<NavItem <NavItem
icon={<Plus className="w-5 h-5" />} icon={<Plus className="w-5 h-5" />}
label={t('dashNewProject')} label={t('dashNewProject')}
active={currentView === 'new-project'} active={currentView === 'new-project'}
onClick={() => onViewChange('new-project')} onClick={() => onViewChange('new-project')}
/> />
{/* 항상 표시: 보관함 */}
<NavItem <NavItem
icon={<FolderOpen className="w-5 h-5" />} icon={<FolderOpen className="w-5 h-5" />}
label={t('libTitle')} label={t('libTitle')}
active={currentView === 'library'} active={currentView === 'library'}
onClick={() => onViewChange('library')} onClick={() => onViewChange('library')}
/> />
<NavItem {/* 중급/프로만: 에셋 라이브러리 */}
icon={<Image className="w-5 h-5" />} {features.showAssetLibrary && (
label={t('dashAssetManage')} <NavItem
active={currentView === 'assets'} icon={<Image className="w-5 h-5" />}
onClick={() => onViewChange('assets')} label={t('dashAssetManage')}
/> active={currentView === 'assets'}
<NavItem onClick={() => onViewChange('assets')}
icon={<Building2 className="w-5 h-5" />} />
label={t('sidebarMyPensions')} )}
active={currentView === 'pensions'} {/* 중급/프로만: 펜션 관리 */}
onClick={() => onViewChange('pensions')} {features.showPensionManagement && (
/> <NavItem
icon={<Building2 className="w-5 h-5" />}
label={t('sidebarMyPensions')}
active={currentView === 'pensions'}
onClick={() => onViewChange('pensions')}
/>
)}
{/* 프로만: 축제 정보 */}
{features.showFestivalMenu && (
<NavItem
icon={<PartyPopper className="w-5 h-5" />}
label="축제 정보"
active={currentView === 'festivals'}
onClick={() => onViewChange('festivals')}
/>
)}
{/* 항상 표시: 계정 */}
<NavItem <NavItem
icon={<User className="w-5 h-5" />} icon={<User className="w-5 h-5" />}
label={t('accountTitle')} label={t('accountTitle')}
active={currentView === 'account'} active={currentView === 'account'}
onClick={() => onViewChange('account')} onClick={() => onViewChange('account')}
/> />
<NavItem {/* 중급/프로만: 비즈니스 설정 */}
icon={<Settings className="w-5 h-5" />} {features.showAdvancedSettings && (
label={t('settingsTitle')} <NavItem
active={currentView === 'settings'} icon={<Settings className="w-5 h-5" />}
onClick={() => onViewChange('settings')} label={t('settingsTitle')}
/> active={currentView === 'settings'}
onClick={() => onViewChange('settings')}
/>
)}
</nav> </nav>
{/* Bottom Section */} {/* Bottom Section */}

View File

@ -0,0 +1,158 @@
import React, { useState } from 'react';
import { UserLevel } from '../../../types';
import { useUserLevel, LEVEL_INFO } from '../../contexts/UserLevelContext';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Check, Loader2 } from 'lucide-react';
import { cn } from '../../lib/utils';
interface LevelSelectorProps {
onSelect?: (level: UserLevel) => void;
showTitle?: boolean;
compact?: boolean;
}
const LevelSelector: React.FC<LevelSelectorProps> = ({
onSelect,
showTitle = true,
compact = false,
}) => {
const { level: currentLevel, setLevel, isLoading } = useUserLevel();
const [isUpdating, setIsUpdating] = useState(false);
const [selectedLevel, setSelectedLevel] = useState<UserLevel | null>(null);
const handleSelect = async (newLevel: UserLevel) => {
if (newLevel === currentLevel || isUpdating) return;
setSelectedLevel(newLevel);
setIsUpdating(true);
try {
await setLevel(newLevel);
onSelect?.(newLevel);
} catch (error) {
console.error('Failed to update level:', error);
} finally {
setIsUpdating(false);
setSelectedLevel(null);
}
};
const levels: UserLevel[] = ['beginner', 'intermediate', 'pro'];
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (compact) {
return (
<div className="flex gap-2">
{levels.map((lvl) => {
const info = LEVEL_INFO[lvl];
const isSelected = lvl === currentLevel;
const isSelecting = selectedLevel === lvl;
return (
<Button
key={lvl}
variant={isSelected ? 'default' : 'outline'}
size="sm"
onClick={() => handleSelect(lvl)}
disabled={isUpdating}
className={cn(
'flex items-center gap-2',
isSelected && 'ring-2 ring-primary ring-offset-2'
)}
>
{isSelecting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<span>{info.icon}</span>
)}
<span>{info.name}</span>
{isSelected && <Check className="h-4 w-4" />}
</Button>
);
})}
</div>
);
}
return (
<div className="space-y-4">
{showTitle && (
<div className="text-center space-y-2">
<h3 className="text-xl font-semibold"> </h3>
<p className="text-muted-foreground text-sm">
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{levels.map((lvl) => {
const info = LEVEL_INFO[lvl];
const isSelected = lvl === currentLevel;
const isSelecting = selectedLevel === lvl;
return (
<Card
key={lvl}
className={cn(
'cursor-pointer transition-all hover:shadow-lg relative overflow-hidden',
isSelected
? 'ring-2 ring-primary border-primary bg-primary/5'
: 'hover:border-primary/50',
isUpdating && !isSelecting && 'opacity-50'
)}
onClick={() => handleSelect(lvl)}
>
{isSelected && (
<div className="absolute top-2 right-2">
<Badge variant="default" className="gap-1">
<Check className="h-3 w-3" />
</Badge>
</div>
)}
<CardHeader className="text-center pb-2">
<div className="text-4xl mb-2">{info.icon}</div>
<CardTitle className="text-lg">
{isSelecting ? (
<Loader2 className="h-5 w-5 animate-spin inline mr-2" />
) : null}
{info.name}
</CardTitle>
<CardDescription className="text-base font-medium text-foreground/80">
"{info.tagline}"
</CardDescription>
</CardHeader>
<CardContent className="text-center space-y-3">
<p className="text-sm text-muted-foreground">
{info.description}
</p>
<ul className="text-sm space-y-1">
{info.features.map((feature, idx) => (
<li key={idx} className="flex items-center justify-center gap-2">
<Check className="h-3 w-3 text-green-500" />
<span>{feature}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
})}
</div>
</div>
);
};
export default LevelSelector;

View File

@ -0,0 +1,219 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import { UserLevel, FeatureFlags } from '../../types';
import { useAuth } from './AuthContext';
interface UserLevelContextType {
level: UserLevel;
setLevel: (level: UserLevel) => Promise<void>;
features: FeatureFlags;
isLoading: boolean;
}
// 레벨별 기능 플래그 정의
const LEVEL_FEATURES: Record<UserLevel, FeatureFlags> = {
beginner: {
// 옵션 선택기 - 모두 숨김
showLanguageSelector: false,
showMusicGenreSelector: false,
showAudioModeSelector: false,
showMusicDurationSelector: false,
showTextEffectSelector: false,
showTransitionSelector: false,
showAspectRatioSelector: false,
showVisualStyleSelector: false,
showTtsConfig: false,
// 고급 기능 - 모두 숨김
showFestivalIntegration: false,
showAiImageGeneration: false,
showCustomCss: false,
showSeoEditor: false,
showThumbnailSelector: false,
// 메뉴 - 최소화
showAssetLibrary: false,
showPensionManagement: false,
showAdvancedSettings: false,
showFestivalMenu: false,
// 자동화 - 강제 ON
autoUpload: true,
showUploadOptions: false,
showScheduleSettings: false,
},
intermediate: {
// 옵션 선택기 - 기본 옵션만
showLanguageSelector: true,
showMusicGenreSelector: true,
showAudioModeSelector: true,
showMusicDurationSelector: true,
showTextEffectSelector: true,
showTransitionSelector: true,
showAspectRatioSelector: true,
showVisualStyleSelector: true,
showTtsConfig: false,
// 고급 기능 - 일부
showFestivalIntegration: false,
showAiImageGeneration: true,
showCustomCss: false,
showSeoEditor: false,
showThumbnailSelector: true,
// 메뉴 - 기본
showAssetLibrary: true,
showPensionManagement: true,
showAdvancedSettings: true,
showFestivalMenu: false,
// 자동화 - 강제 ON
autoUpload: true,
showUploadOptions: false,
showScheduleSettings: false,
},
pro: {
// 옵션 선택기 - 전체
showLanguageSelector: true,
showMusicGenreSelector: true,
showAudioModeSelector: true,
showMusicDurationSelector: true,
showTextEffectSelector: true,
showTransitionSelector: true,
showAspectRatioSelector: true,
showVisualStyleSelector: true,
showTtsConfig: true,
// 고급 기능 - 전체
showFestivalIntegration: true,
showAiImageGeneration: true,
showCustomCss: true,
showSeoEditor: true,
showThumbnailSelector: true,
// 메뉴 - 전체
showAssetLibrary: true,
showPensionManagement: true,
showAdvancedSettings: true,
showFestivalMenu: true,
// 자동화 - 선택 가능
autoUpload: false,
showUploadOptions: true,
showScheduleSettings: true,
},
};
// 레벨별 기본값 (Beginner용)
export const BEGINNER_DEFAULTS = {
language: 'KO' as const,
musicGenre: 'Auto' as const,
audioMode: 'Song' as const,
musicDuration: 'Short' as const,
visualStyle: 'Video' as const,
textEffect: 'Cinematic' as const,
transitionEffect: 'Mix' as const,
aspectRatio: '9:16' as const,
};
const UserLevelContext = createContext<UserLevelContextType | undefined>(undefined);
export const UserLevelProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, token } = useAuth();
const [level, setLevelState] = useState<UserLevel>('beginner');
const [isLoading, setIsLoading] = useState(true);
// 사용자 레벨 로드
useEffect(() => {
const loadLevel = async () => {
if (!token || !user) {
setLevelState('beginner');
setIsLoading(false);
return;
}
try {
const res = await fetch('/api/user/level', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setLevelState(data.level || 'beginner');
}
} catch (error) {
console.error('Failed to load user level:', error);
} finally {
setIsLoading(false);
}
};
loadLevel();
}, [token, user]);
// 레벨 변경
const setLevel = useCallback(async (newLevel: UserLevel) => {
if (!token) return;
try {
const res = await fetch('/api/user/level', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ level: newLevel }),
});
if (res.ok) {
setLevelState(newLevel);
}
} catch (error) {
console.error('Failed to update user level:', error);
throw error;
}
}, [token]);
// 현재 레벨의 기능 플래그
const features = useMemo(() => LEVEL_FEATURES[level], [level]);
return (
<UserLevelContext.Provider value={{ level, setLevel, features, isLoading }}>
{children}
</UserLevelContext.Provider>
);
};
export const useUserLevel = () => {
const context = useContext(UserLevelContext);
if (!context) {
throw new Error('useUserLevel must be used within a UserLevelProvider');
}
return context;
};
// 레벨 정보 헬퍼
export const LEVEL_INFO = {
beginner: {
name: '쌩초보',
nameEn: 'Beginner',
icon: '🌱',
tagline: '다 알아서 해줘',
description: '원클릭으로 영상 생성, 자동 업로드',
features: ['원클릭 생성', '자동 업로드', '간단한 메뉴'],
},
intermediate: {
name: '중급',
nameEn: 'Intermediate',
icon: '🌿',
tagline: '조금만 선택할게',
description: '음악 장르, 언어 등 기본 옵션 선택',
features: ['장르 선택', '언어 선택', '기본 메뉴'],
},
pro: {
name: '프로',
nameEn: 'Pro',
icon: '🌳',
tagline: '내가 다 컨트롤',
description: '모든 옵션 제어, 축제 연동, 고급 설정',
features: ['모든 옵션', '축제 연동', '고급 설정'],
},
};

View File

@ -370,7 +370,7 @@ export const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
npTransZoom: '줌', npTransZoom: '줌',
npTransSlide: '슬라이드', npTransSlide: '슬라이드',
npTransWipe: '와이프', npTransWipe: '와이프',
npMaxImages: '최대 15장 · JPG, PNG, WEBP', npMaxImages: '최대 100장 · JPG, PNG, WEBP',
// Dashboard // Dashboard
dashWelcome: '환영합니다!', dashWelcome: '환영합니다!',
dashSubtitle: 'AI로 펜션 홍보 영상을 손쉽게 만들어보세요', dashSubtitle: 'AI로 펜션 홍보 영상을 손쉽게 만들어보세요',
@ -838,7 +838,7 @@ export const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
npTransZoom: 'Zoom', npTransZoom: 'Zoom',
npTransSlide: 'Slide', npTransSlide: 'Slide',
npTransWipe: 'Wipe', npTransWipe: 'Wipe',
npMaxImages: 'Max 15 images · JPG, PNG, WEBP', npMaxImages: 'Max 100 images · JPG, PNG, WEBP',
// Dashboard // Dashboard
dashWelcome: 'Welcome!', dashWelcome: 'Welcome!',
dashSubtitle: 'Create pension promo videos easily with AI', dashSubtitle: 'Create pension promo videos easily with AI',
@ -1306,7 +1306,7 @@ export const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
npTransZoom: 'ズーム', npTransZoom: 'ズーム',
npTransSlide: 'スライド', npTransSlide: 'スライド',
npTransWipe: 'ワイプ', npTransWipe: 'ワイプ',
npMaxImages: '最大15枚 · JPG, PNG, WEBP', npMaxImages: '最大100枚 · JPG, PNG, WEBP',
// Dashboard // Dashboard
dashWelcome: 'ようこそ!', dashWelcome: 'ようこそ!',
dashSubtitle: 'AIでペンションPR動画を簡単に作成', dashSubtitle: 'AIでペンションPR動画を簡単に作成',
@ -1774,7 +1774,7 @@ export const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
npTransZoom: '缩放', npTransZoom: '缩放',
npTransSlide: '滑动', npTransSlide: '滑动',
npTransWipe: '擦除', npTransWipe: '擦除',
npMaxImages: '最多15张 · JPG, PNG, WEBP', npMaxImages: '最多100张 · JPG, PNG, WEBP',
// Dashboard // Dashboard
dashWelcome: '欢迎!', dashWelcome: '欢迎!',
dashSubtitle: '用AI轻松制作民宿宣传视频', dashSubtitle: '用AI轻松制作民宿宣传视频',
@ -2242,7 +2242,7 @@ export const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
npTransZoom: 'ซูม', npTransZoom: 'ซูม',
npTransSlide: 'เลื่อน', npTransSlide: 'เลื่อน',
npTransWipe: 'กวาด', npTransWipe: 'กวาด',
npMaxImages: 'สูงสุด 15 รูป · JPG, PNG, WEBP', npMaxImages: 'สูงสุด 100 รูป · JPG, PNG, WEBP',
// Dashboard // Dashboard
dashWelcome: 'ยินดีต้อนรับ!', dashWelcome: 'ยินดีต้อนรับ!',
dashSubtitle: 'สร้างวิดีโอโปรโมทเพนชั่นง่ายๆ ด้วย AI', dashSubtitle: 'สร้างวิดีโอโปรโมทเพนชั่นง่ายๆ ด้วย AI',
@ -2710,7 +2710,7 @@ export const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
npTransZoom: 'Phóng to', npTransZoom: 'Phóng to',
npTransSlide: 'Trượt', npTransSlide: 'Trượt',
npTransWipe: 'Xóa', npTransWipe: 'Xóa',
npMaxImages: 'Tối đa 15 ảnh · JPG, PNG, WEBP', npMaxImages: 'Tối đa 100 ảnh · JPG, PNG, WEBP',
// Dashboard // Dashboard
dashWelcome: 'Chào mừng!', dashWelcome: 'Chào mừng!',
dashSubtitle: 'Tạo video quảng cáo pension dễ dàng với AI', dashSubtitle: 'Tạo video quảng cáo pension dễ dàng với AI',

View File

@ -53,10 +53,15 @@ import {
AlertCircle, AlertCircle,
Info, Info,
LogOut, LogOut,
Coins Coins,
PartyPopper,
Home,
MapPin,
Zap,
DollarSign
} from 'lucide-react'; } from 'lucide-react';
type TabType = 'overview' | 'users' | 'content' | 'logs' | 'health' | 'settings' | 'credits'; type TabType = 'overview' | 'users' | 'content' | 'logs' | 'health' | 'settings' | 'credits' | 'api-usage';
interface Stats { interface Stats {
users: { users: {
@ -194,9 +199,41 @@ const AdminDashboard: React.FC = () => {
const [editCredits, setEditCredits] = useState(''); const [editCredits, setEditCredits] = useState('');
const [savingPlan, setSavingPlan] = useState(false); const [savingPlan, setSavingPlan] = useState(false);
// API Usage states
const [apiUsageStats, setApiUsageStats] = useState<any>(null);
const [apiUsageByUser, setApiUsageByUser] = useState<any[]>([]);
const [apiUsageDays, setApiUsageDays] = useState(30);
const [apiUsageLoading, setApiUsageLoading] = useState(false);
const [selectedApiUser, setSelectedApiUser] = useState<any>(null);
const [apiUserDetail, setApiUserDetail] = useState<any>(null);
// Google Cloud Billing states (BigQuery)
const [gcpBillingData, setGcpBillingData] = useState<any>(null);
const [gcpBillingStatus, setGcpBillingStatus] = useState<any>(null);
const [gcpBillingLoading, setGcpBillingLoading] = useState(false);
// Data sync states
const [syncingFestivals, setSyncingFestivals] = useState(false);
const [syncingPensions, setSyncingPensions] = useState(false);
const [festivalStats, setFestivalStats] = useState<{stats: any[], total: number} | null>(null);
const [pensionStats, setPensionStats] = useState<{stats: any[], total: number} | null>(null);
const [lastSyncMessage, setLastSyncMessage] = useState<string | null>(null);
// Cookie management states
const [cookieSettings, setCookieSettings] = useState<{
naver_cookies: { value: string; masked: string; updatedAt: string | null; source?: string };
instagram_cookies: { value: string; masked: string; updatedAt: string | null; source?: string };
} | null>(null);
const [editingCookie, setEditingCookie] = useState<'naver' | 'instagram' | null>(null);
const [cookieInputValue, setCookieInputValue] = useState('');
const [savingCookie, setSavingCookie] = useState(false);
useEffect(() => { useEffect(() => {
if (!isAdmin) navigate('/dashboard'); // currentUser가 로드된 후에만 권한 체크 (null이면 아직 로딩 중)
}, [isAdmin, navigate]); if (currentUser !== null && !isAdmin) {
navigate('/app');
}
}, [currentUser, isAdmin, navigate]);
// Fetch functions // Fetch functions
const fetchStats = useCallback(async () => { const fetchStats = useCallback(async () => {
@ -271,6 +308,84 @@ const AdminDashboard: React.FC = () => {
} }
}, [token]); }, [token]);
// API Usage fetch functions
const fetchApiUsageStats = useCallback(async () => {
setApiUsageLoading(true);
try {
const res = await fetch(`/api/admin/api-usage/stats?days=${apiUsageDays}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setApiUsageStats(data);
}
} catch (e) {
console.error('API usage stats fetch error:', e);
} finally {
setApiUsageLoading(false);
}
}, [token, apiUsageDays]);
const fetchApiUsageByUser = useCallback(async () => {
try {
const res = await fetch(`/api/admin/api-usage/by-user?days=${apiUsageDays}&limit=50`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setApiUsageByUser(data.users || []);
}
} catch (e) {
console.error('API usage by user fetch error:', e);
}
}, [token, apiUsageDays]);
const fetchApiUserDetail = useCallback(async (userId: number) => {
try {
const res = await fetch(`/api/admin/api-usage/user/${userId}?days=${apiUsageDays}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setApiUserDetail(data);
}
} catch (e) {
console.error('API user detail fetch error:', e);
}
}, [token, apiUsageDays]);
// Google Cloud Billing fetch functions
const fetchGcpBillingStatus = useCallback(async () => {
try {
const res = await fetch('/api/admin/billing/status', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setGcpBillingStatus(data);
}
} catch (e) {
console.error('GCP billing status fetch error:', e);
}
}, [token]);
const fetchGcpBillingData = useCallback(async () => {
setGcpBillingLoading(true);
try {
const res = await fetch(`/api/admin/billing/dashboard?days=${apiUsageDays}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setGcpBillingData(data);
}
} catch (e) {
console.error('GCP billing data fetch error:', e);
} finally {
setGcpBillingLoading(false);
}
}, [token, apiUsageDays]);
// Credit fetch functions // Credit fetch functions
const fetchCreditRequests = useCallback(async () => { const fetchCreditRequests = useCallback(async () => {
try { try {
@ -301,6 +416,118 @@ const AdminDashboard: React.FC = () => {
} }
}, [token]); }, [token]);
// Festival/Pension stats fetch functions
const fetchFestivalStats = useCallback(async () => {
try {
const res = await fetch('/api/festivals/stats/by-region', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) setFestivalStats(await res.json());
} catch (e) {
console.error('Festival stats fetch error:', e);
}
}, [token]);
const fetchPensionStats = useCallback(async () => {
try {
const res = await fetch('/api/pensions/stats/by-region', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) setPensionStats(await res.json());
} catch (e) {
console.error('Pension stats fetch error:', e);
}
}, [token]);
// Cookie management functions
const fetchCookieSettings = useCallback(async () => {
try {
const res = await fetch('/api/admin/cookies', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) setCookieSettings(await res.json());
} catch (e) {
console.error('Cookie settings fetch error:', e);
}
}, [token]);
const handleSaveCookie = async (type: 'naver' | 'instagram') => {
setSavingCookie(true);
try {
const body: any = {};
if (type === 'naver') body.naver_cookies = cookieInputValue;
if (type === 'instagram') body.instagram_cookies = cookieInputValue;
const res = await fetch('/api/admin/cookies', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(body)
});
if (res.ok) {
await fetchCookieSettings();
setEditingCookie(null);
setCookieInputValue('');
}
} catch (e) {
console.error('Cookie save error:', e);
} finally {
setSavingCookie(false);
}
};
// Sync handlers
const handleSyncFestivals = async () => {
setSyncingFestivals(true);
setLastSyncMessage(null);
try {
const res = await fetch('/api/admin/sync/festivals', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
const data = await res.json();
setLastSyncMessage(data.message || '축제 동기화가 시작되었습니다.');
// Refresh stats after a delay
setTimeout(() => {
fetchFestivalStats();
}, 3000);
} catch (e) {
setLastSyncMessage('축제 동기화 요청 실패');
} finally {
setSyncingFestivals(false);
}
};
const handleSyncPensions = async () => {
setSyncingPensions(true);
setLastSyncMessage(null);
try {
const res = await fetch('/api/admin/sync/pensions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
const data = await res.json();
setLastSyncMessage(data.message || '펜션 동기화가 시작되었습니다.');
// Refresh stats after a delay
setTimeout(() => {
fetchPensionStats();
}, 3000);
} catch (e) {
setLastSyncMessage('펜션 동기화 요청 실패');
} finally {
setSyncingPensions(false);
}
};
// Credit action functions // Credit action functions
const handleCreditAction = async (requestId: number, action: 'approve' | 'reject', adminNote?: string) => { const handleCreditAction = async (requestId: number, action: 'approve' | 'reject', adminNote?: string) => {
setProcessingCredit(requestId); setProcessingCredit(requestId);
@ -374,11 +601,17 @@ const AdminDashboard: React.FC = () => {
case 'health': case 'health':
await fetchHealth(); await fetchHealth();
break; break;
case 'settings':
await Promise.all([fetchFestivalStats(), fetchPensionStats(), fetchCookieSettings()]);
break;
case 'api-usage':
await Promise.all([fetchApiUsageStats(), fetchApiUsageByUser(), fetchGcpBillingStatus(), fetchGcpBillingData()]);
break;
} }
setIsLoading(false); setIsLoading(false);
}; };
loadData(); loadData();
}, [activeTab, fetchStats, fetchHealth, fetchUsers, fetchHistory, fetchLogs, fetchUploads, fetchCreditRequests, fetchCreditStats]); }, [activeTab, fetchStats, fetchHealth, fetchUsers, fetchHistory, fetchLogs, fetchUploads, fetchCreditRequests, fetchCreditStats, fetchFestivalStats, fetchPensionStats, fetchApiUsageStats, fetchApiUsageByUser, fetchGcpBillingStatus, fetchGcpBillingData]);
// User actions // User actions
const handleApprove = async (userId: number, approve: boolean) => { const handleApprove = async (userId: number, approve: boolean) => {
@ -533,6 +766,7 @@ const AdminDashboard: React.FC = () => {
{ id: 'overview', label: '대시보드', icon: LayoutDashboard }, { id: 'overview', label: '대시보드', icon: LayoutDashboard },
{ id: 'users', label: '회원 관리', icon: Users }, { id: 'users', label: '회원 관리', icon: Users },
{ id: 'credits', label: '크레딧', icon: Coins }, { id: 'credits', label: '크레딧', icon: Coins },
{ id: 'api-usage', label: 'API 사용량', icon: Zap },
{ id: 'content', label: '콘텐츠', icon: Video }, { id: 'content', label: '콘텐츠', icon: Video },
{ id: 'logs', label: '활동 로그', icon: Activity }, { id: 'logs', label: '활동 로그', icon: Activity },
{ id: 'health', label: '시스템', icon: Server }, { id: 'health', label: '시스템', icon: Server },
@ -1344,6 +1578,404 @@ const AdminDashboard: React.FC = () => {
</div> </div>
)} )}
{/* API Usage Tab */}
{activeTab === 'api-usage' && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">API </h2>
<div className="flex items-center gap-2">
<select
value={apiUsageDays}
onChange={(e) => setApiUsageDays(Number(e.target.value))}
className="px-3 py-1.5 rounded-lg border bg-background text-sm"
>
<option value={7}> 7</option>
<option value={30}> 30</option>
<option value={90}> 90</option>
</select>
<Button variant="outline" size="sm" onClick={() => { fetchApiUsageStats(); fetchApiUsageByUser(); }}>
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" size="sm" onClick={() => window.open('https://aistudio.google.com/app/apikey', '_blank')}>
<ExternalLink className="w-4 h-4 mr-2" />
Google AI Studio
</Button>
</div>
</div>
{/* Overview Cards */}
{apiUsageStats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Zap className="w-4 h-4" />
<span className="text-xs"> API </span>
</div>
<p className="text-2xl font-bold">{apiUsageStats.overview?.totalCalls?.toLocaleString() || 0}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<DollarSign className="w-4 h-4" />
<span className="text-xs"> (USD)</span>
</div>
<p className="text-2xl font-bold">${apiUsageStats.overview?.totalCostUSD || '0.00'}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<AlertCircle className="w-4 h-4" />
<span className="text-xs"></span>
</div>
<p className="text-2xl font-bold">{apiUsageStats.overview?.errorRate || 0}%</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Clock className="w-4 h-4" />
<span className="text-xs"> </span>
</div>
<p className="text-2xl font-bold">{apiUsageStats.overview?.avgLatencyMs || 0}ms</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<XCircle className="w-4 h-4 text-red-500" />
<span className="text-xs"> </span>
</div>
<p className="text-2xl font-bold text-red-500">{apiUsageStats.overview?.totalErrors || 0}</p>
</CardContent>
</Card>
</div>
)}
{/* Service/Model Breakdown */}
{apiUsageStats?.byServiceModel && apiUsageStats.byServiceModel.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">/ </CardTitle>
<CardDescription> AI </CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3"></th>
<th className="text-left py-2 px-3"></th>
<th className="text-right py-2 px-3"></th>
<th className="text-right py-2 px-3"></th>
<th className="text-right py-2 px-3"></th>
<th className="text-right py-2 px-3"></th>
<th className="text-right py-2 px-3"> (USD)</th>
<th className="text-right py-2 px-3"> </th>
</tr>
</thead>
<tbody>
{apiUsageStats.byServiceModel.map((item: any, idx: number) => (
<tr key={idx} className="border-b hover:bg-muted/50">
<td className="py-2 px-3">
<Badge variant="outline">{item.service}</Badge>
</td>
<td className="py-2 px-3 font-mono text-xs">{item.model || '-'}</td>
<td className="text-right py-2 px-3">{item.total_calls?.toLocaleString()}</td>
<td className="text-right py-2 px-3 text-green-500">{item.success_count?.toLocaleString()}</td>
<td className="text-right py-2 px-3 text-red-500">{item.error_count || 0}</td>
<td className="text-right py-2 px-3">{item.total_images || 0}</td>
<td className="text-right py-2 px-3 font-medium">${(item.total_cost || 0).toFixed(4)}</td>
<td className="text-right py-2 px-3">{Math.round(item.avg_latency || 0)}ms</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* User Usage Ranking */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Users className="w-5 h-5" />
API ()
</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
{apiUsageByUser.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3"></th>
<th className="text-left py-2 px-3"></th>
<th className="text-right py-2 px-3"></th>
<th className="text-right py-2 px-3"></th>
<th className="text-right py-2 px-3">()</th>
<th className="text-right py-2 px-3"> (USD)</th>
<th className="text-right py-2 px-3"> (KRW)</th>
<th className="text-center py-2 px-3"></th>
</tr>
</thead>
<tbody>
{apiUsageByUser.map((user: any) => (
<tr key={user.userId} className="border-b hover:bg-muted/50">
<td className="py-2 px-3">
<div>
<p className="font-medium">{user.username}</p>
<p className="text-xs text-muted-foreground">{user.email || user.name}</p>
</div>
</td>
<td className="py-2 px-3">
<Badge variant={user.planType === 'free' ? 'secondary' : 'default'}>
{user.planType}
</Badge>
</td>
<td className="text-right py-2 px-3">{user.totalCalls?.toLocaleString()}</td>
<td className="text-right py-2 px-3">{user.totalImages || 0}</td>
<td className="text-right py-2 px-3">{user.totalAudioSeconds || 0}</td>
<td className="text-right py-2 px-3 font-medium">${user.costUSD?.toFixed(4)}</td>
<td className="text-right py-2 px-3 font-medium text-green-600">{user.costKRW?.toLocaleString()}</td>
<td className="text-center py-2 px-3">
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedApiUser(user);
fetchApiUserDetail(user.userId);
}}
>
<Eye className="w-4 h-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-center text-muted-foreground py-8">
API .
</p>
)}
</CardContent>
</Card>
{/* Recent Errors */}
{apiUsageStats?.recentErrors && apiUsageStats.recentErrors.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-500" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-64 overflow-y-auto">
{apiUsageStats.recentErrors.map((error: any) => (
<div key={error.id} className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm">
<div className="flex items-center justify-between mb-1">
<span className="font-medium">{error.service} / {error.model}</span>
<span className="text-xs text-muted-foreground">
{new Date(error.createdAt).toLocaleString('ko-KR')}
</span>
</div>
<p className="text-red-600 dark:text-red-400 text-xs font-mono">
{error.error_message}
</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Google Cloud Billing (BigQuery) - 실제 결제 데이터 */}
<Card className="border-2 border-blue-200 dark:border-blue-800">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<DollarSign className="w-5 h-5 text-blue-500" />
Google Cloud
<Badge variant="outline" className="ml-2 text-xs">BigQuery</Badge>
</CardTitle>
<CardDescription>
BigQuery Export ( )
</CardDescription>
</CardHeader>
<CardContent>
{gcpBillingLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
<span className="ml-2 text-muted-foreground"> ...</span>
</div>
) : gcpBillingStatus?.available === false ? (
<div className="text-center py-8">
<AlertCircle className="w-12 h-12 text-yellow-500 mx-auto mb-3" />
<p className="font-medium"> </p>
<p className="text-sm text-muted-foreground mt-1">
BigQuery Export 24-48 .
</p>
<p className="text-xs text-muted-foreground mt-2">
{gcpBillingStatus?.message}
</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => window.open('https://console.cloud.google.com/billing', '_blank')}
>
<ExternalLink className="w-4 h-4 mr-2" />
Google Cloud Billing
</Button>
</div>
) : gcpBillingData?.error ? (
<div className="text-center py-8">
<XCircle className="w-12 h-12 text-red-500 mx-auto mb-3" />
<p className="text-red-500">{gcpBillingData.error}</p>
</div>
) : gcpBillingData ? (
<div className="space-y-4">
{/* 실제 비용 요약 */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-center">
<p className="text-xs text-muted-foreground mb-1"> (USD)</p>
<p className="text-2xl font-bold text-blue-600">${gcpBillingData.summary?.totalCostUSD || '0.00'}</p>
</div>
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg text-center">
<p className="text-xs text-muted-foreground mb-1"> (KRW)</p>
<p className="text-2xl font-bold text-green-600">{gcpBillingData.summary?.totalCostKRW?.toLocaleString() || 0}</p>
</div>
<div className="p-4 bg-muted rounded-lg text-center">
<p className="text-xs text-muted-foreground mb-1"> </p>
<p className="text-2xl font-bold">{gcpBillingData.summary?.serviceCount || 0}</p>
</div>
</div>
{/* 서비스별 비용 */}
{gcpBillingData.byService && gcpBillingData.byService.length > 0 && (
<div>
<h4 className="font-medium mb-2 flex items-center gap-2">
<Server className="w-4 h-4" />
</h4>
<div className="space-y-2">
{gcpBillingData.byService.slice(0, 5).map((service: any, idx: number) => (
<div key={idx} className="flex items-center justify-between p-2 bg-muted/50 rounded">
<span className="text-sm">{service.serviceName}</span>
<span className="font-mono text-sm font-medium">
${service.actualCost?.toFixed(4)} {service.currency}
</span>
</div>
))}
</div>
</div>
)}
{/* Gemini/Vertex AI 상세 */}
{gcpBillingData.geminiUsage && gcpBillingData.geminiUsage.length > 0 && (
<div>
<h4 className="font-medium mb-2 flex items-center gap-2">
<Zap className="w-4 h-4 text-purple-500" />
Gemini / Vertex AI
</h4>
<div className="space-y-1 max-h-32 overflow-y-auto">
{gcpBillingData.geminiUsage.map((sku: any, idx: number) => (
<div key={idx} className="flex items-center justify-between p-2 bg-purple-50 dark:bg-purple-900/20 rounded text-sm">
<span className="truncate flex-1 mr-2">{sku.skuName}</span>
<span className="font-mono">${sku.totalCost?.toFixed(6)}</span>
</div>
))}
</div>
</div>
)}
<div className="text-xs text-muted-foreground text-right">
: {gcpBillingData.dataSource}
</div>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
...
</div>
)}
</CardContent>
</Card>
{/* User Detail Modal */}
{selectedApiUser && apiUserDetail && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-2xl max-h-[80vh] overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>{apiUserDetail.user?.username} </CardTitle>
<CardDescription>{apiUserDetail.period} </CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={() => { setSelectedApiUser(null); setApiUserDetail(null); }}>
<X className="w-4 h-4" />
</Button>
</CardHeader>
<CardContent className="overflow-y-auto max-h-[60vh]">
<div className="space-y-4">
{/* Summary */}
<div className="grid grid-cols-3 gap-3">
<div className="p-3 bg-muted rounded-lg text-center">
<p className="text-xs text-muted-foreground"> (USD)</p>
<p className="text-xl font-bold">${apiUserDetail.summary?.totalCostUSD}</p>
</div>
<div className="p-3 bg-muted rounded-lg text-center">
<p className="text-xs text-muted-foreground"> (KRW)</p>
<p className="text-xl font-bold text-green-600">{apiUserDetail.summary?.totalCostKRW?.toLocaleString()}</p>
</div>
<div className="p-3 bg-muted rounded-lg text-center">
<p className="text-xs text-muted-foreground"> </p>
<p className="text-xl font-bold">{apiUserDetail.summary?.totalCalls}</p>
</div>
</div>
{/* Usage by Model */}
{apiUserDetail.usageByModel && (
<div>
<h4 className="font-medium mb-2"> </h4>
<div className="space-y-1">
{apiUserDetail.usageByModel.map((m: any, i: number) => (
<div key={i} className="flex items-center justify-between p-2 bg-muted/50 rounded text-sm">
<span className="font-mono text-xs">{m.model}</span>
<span>${(m.cost || 0).toFixed(4)}</span>
</div>
))}
</div>
</div>
)}
{/* Daily Trend */}
{apiUserDetail.dailyTrend && apiUserDetail.dailyTrend.length > 0 && (
<div>
<h4 className="font-medium mb-2"> </h4>
<div className="space-y-1 max-h-32 overflow-y-auto">
{apiUserDetail.dailyTrend.slice(0, 7).map((d: any, i: number) => (
<div key={i} className="flex items-center justify-between p-2 bg-muted/50 rounded text-sm">
<span>{d.date}</span>
<span>{d.calls} / ${(d.cost || 0).toFixed(4)}</span>
</div>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
)}
</div>
)}
{/* Content Tab */} {/* Content Tab */}
{activeTab === 'content' && ( {activeTab === 'content' && (
<div className="space-y-6"> <div className="space-y-6">
@ -1650,13 +2282,281 @@ const AdminDashboard: React.FC = () => {
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-2xl font-bold"></h2> <h2 className="text-2xl font-bold"></h2>
{/* Sync Message */}
{lastSyncMessage && (
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30 text-blue-600 dark:text-blue-400 text-sm flex items-center gap-2">
<Info className="w-4 h-4 shrink-0" />
{lastSyncMessage}
</div>
)}
{/* Data Sync Section */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> </CardTitle> <CardTitle className="flex items-center gap-2">
<CardDescription> </CardDescription> <Database className="w-5 h-5" />
</CardTitle>
<CardDescription>
TourAPI . 3 .
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-6">
<p className="text-muted-foreground"> .</p> {/* Festival Sync */}
<div className="p-4 rounded-lg border bg-card">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-orange-500/10 flex items-center justify-center">
<PartyPopper className="w-5 h-5 text-orange-500" />
</div>
<div>
<h4 className="font-medium"> </h4>
<p className="text-sm text-muted-foreground">
/ (TourAPI)
</p>
</div>
</div>
<Button
onClick={handleSyncFestivals}
disabled={syncingFestivals}
variant="outline"
>
{syncingFestivals ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
{/* Festival Stats */}
{festivalStats && (
<div className="mt-4 pt-4 border-t">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium"> </span>
<Badge variant="secondary">{festivalStats.total}</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{festivalStats.stats.slice(0, 8).map((item: any) => (
<div key={item.sido} className="text-xs p-2 rounded bg-muted/50">
<span className="text-muted-foreground">{item.sido}</span>
<span className="float-right font-medium">{item.count}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Pension Sync */}
<div className="p-4 rounded-lg border bg-card">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
<Home className="w-5 h-5 text-green-500" />
</div>
<div>
<h4 className="font-medium"> </h4>
<p className="text-sm text-muted-foreground">
/ (TourAPI)
</p>
</div>
</div>
<Button
onClick={handleSyncPensions}
disabled={syncingPensions}
variant="outline"
>
{syncingPensions ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
{/* Pension Stats */}
{pensionStats && (
<div className="mt-4 pt-4 border-t">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium"> </span>
<Badge variant="secondary">{pensionStats.total}</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{pensionStats.stats.slice(0, 8).map((item: any) => (
<div key={item.sido} className="text-xs p-2 rounded bg-muted/50">
<span className="text-muted-foreground">{item.sido}</span>
<span className="float-right font-medium">{item.count}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Sync Info */}
<div className="p-3 rounded-lg bg-muted/50 text-sm text-muted-foreground">
<p className="flex items-center gap-2">
<Clock className="w-4 h-4" />
: 3
</p>
<p className="mt-1 text-xs">
. .
</p>
</div>
</CardContent>
</Card>
{/* Cookie Management */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="w-5 h-5" />
API
</CardTitle>
<CardDescription>
, .
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Naver Cookies */}
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-green-600" />
<span className="font-medium"> </span>
</div>
{editingCookie === 'naver' ? (
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => { setEditingCookie(null); setCookieInputValue(''); }}
>
</Button>
<Button
size="sm"
onClick={() => handleSaveCookie('naver')}
disabled={savingCookie}
>
{savingCookie ? <Loader2 className="w-4 h-4 animate-spin" /> : '저장'}
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
onClick={() => {
setEditingCookie('naver');
setCookieInputValue(cookieSettings?.naver_cookies?.value || '');
}}
>
</Button>
)}
</div>
{editingCookie === 'naver' ? (
<textarea
className="w-full h-24 p-2 text-xs font-mono border rounded bg-muted"
value={cookieInputValue}
onChange={(e) => setCookieInputValue(e.target.value)}
placeholder="NNB=xxx; JSESSIONID=xxx; ..."
/>
) : (
<div className="text-sm text-muted-foreground">
{cookieSettings?.naver_cookies?.masked || '설정되지 않음'}
{cookieSettings?.naver_cookies?.source === 'env' && (
<Badge variant="outline" className="ml-2">.env</Badge>
)}
</div>
)}
{cookieSettings?.naver_cookies?.updatedAt && (
<p className="mt-1 text-xs text-muted-foreground">
: {new Date(cookieSettings.naver_cookies.updatedAt).toLocaleString('ko-KR')}
</p>
)}
</div>
{/* Instagram Cookies */}
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Instagram className="w-4 h-4 text-pink-600" />
<span className="font-medium"> </span>
</div>
{editingCookie === 'instagram' ? (
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => { setEditingCookie(null); setCookieInputValue(''); }}
>
</Button>
<Button
size="sm"
onClick={() => handleSaveCookie('instagram')}
disabled={savingCookie}
>
{savingCookie ? <Loader2 className="w-4 h-4 animate-spin" /> : '저장'}
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
onClick={() => {
setEditingCookie('instagram');
setCookieInputValue(cookieSettings?.instagram_cookies?.value || '');
}}
>
</Button>
)}
</div>
{editingCookie === 'instagram' ? (
<textarea
className="w-full h-24 p-2 text-xs font-mono border rounded bg-muted"
value={cookieInputValue}
onChange={(e) => setCookieInputValue(e.target.value)}
placeholder="sessionid=xxx; csrftoken=xxx; ds_user_id=xxx; ..."
/>
) : (
<div className="text-sm text-muted-foreground">
{cookieSettings?.instagram_cookies?.masked || '설정되지 않음'}
{cookieSettings?.instagram_cookies?.source === 'env' && (
<Badge variant="outline" className="ml-2">.env</Badge>
)}
</div>
)}
{cookieSettings?.instagram_cookies?.updatedAt && (
<p className="mt-1 text-xs text-muted-foreground">
: {new Date(cookieSettings.instagram_cookies.updatedAt).toLocaleString('ko-KR')}
</p>
)}
</div>
<div className="p-3 text-xs bg-amber-50 dark:bg-amber-900/20 rounded-lg">
<p className="font-medium text-amber-800 dark:text-amber-200"> :</p>
<ol className="mt-1 ml-4 list-decimal text-amber-700 dark:text-amber-300">
<li> </li>
<li> (F12) Application Cookies</li>
<li> "이름=값; 이름=값; ..." </li>
</ol>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -2,9 +2,11 @@ import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { ThemeProvider } from '../contexts/ThemeContext'; import { ThemeProvider } from '../contexts/ThemeContext';
import { UserLevelProvider } from '../contexts/UserLevelContext';
import Sidebar, { ViewType } from '../components/layout/Sidebar'; import Sidebar, { ViewType } from '../components/layout/Sidebar';
import TopHeader from '../components/layout/TopHeader'; import TopHeader from '../components/layout/TopHeader';
import OnboardingDialog from '../components/OnboardingDialog'; import OnboardingDialog from '../components/OnboardingDialog';
import PensionOnboardingDialog from '../components/PensionOnboardingDialog';
import DashboardView from '../views/DashboardView'; import DashboardView from '../views/DashboardView';
import NewProjectView from '../views/NewProjectView'; import NewProjectView from '../views/NewProjectView';
import LibraryView from '../views/LibraryView'; import LibraryView from '../views/LibraryView';
@ -12,6 +14,7 @@ import AssetsView from '../views/AssetsView';
import AccountView from '../views/AccountView'; import AccountView from '../views/AccountView';
import SettingsView from '../views/SettingsView'; import SettingsView from '../views/SettingsView';
import PensionsView from '../views/PensionsView'; import PensionsView from '../views/PensionsView';
import FestivalsView from '../views/FestivalsView';
import { GeneratedAssets } from '../../types'; import { GeneratedAssets } from '../../types';
const CastADApp: React.FC = () => { const CastADApp: React.FC = () => {
@ -92,6 +95,7 @@ const CastADApp: React.FC = () => {
setSelectedAsset(asset); setSelectedAsset(asset);
setCurrentView('library'); setCurrentView('library');
}} }}
onViewFestivals={() => handleViewChange('festivals')}
/> />
); );
case 'new-project': case 'new-project':
@ -114,6 +118,23 @@ const CastADApp: React.FC = () => {
return <AssetsView />; return <AssetsView />;
case 'pensions': case 'pensions':
return <PensionsView />; return <PensionsView />;
case 'festivals':
return (
<FestivalsView
onCreateWithFestival={(festival) => {
// Store festival data and navigate to new project
sessionStorage.setItem('preselectedFestival', JSON.stringify({
id: festival.id,
title: festival.title,
addr1: festival.addr1,
sido: festival.sido,
event_start_date: festival.event_start_date,
event_end_date: festival.event_end_date
}));
handleViewChange('new-project');
}}
/>
);
case 'account': case 'account':
return <AccountView />; return <AccountView />;
case 'settings': case 'settings':
@ -139,6 +160,8 @@ const CastADApp: React.FC = () => {
return { breadcrumb: '', title: '에셋 라이브러리' }; return { breadcrumb: '', title: '에셋 라이브러리' };
case 'pensions': case 'pensions':
return { breadcrumb: '', title: '내 펜션 관리' }; return { breadcrumb: '', title: '내 펜션 관리' };
case 'festivals':
return { breadcrumb: '', title: '전국 축제 정보' };
case 'account': case 'account':
return { breadcrumb: '', title: '계정 관리' }; return { breadcrumb: '', title: '계정 관리' };
case 'settings': case 'settings':
@ -152,21 +175,25 @@ const CastADApp: React.FC = () => {
return ( return (
<ThemeProvider> <ThemeProvider>
<div className="flex h-screen bg-background overflow-hidden"> <UserLevelProvider>
<Sidebar <div className="flex h-screen bg-background overflow-hidden">
currentView={currentView} <Sidebar
onViewChange={handleViewChange} currentView={currentView}
libraryCount={libraryItems.length} onViewChange={handleViewChange}
/> libraryCount={libraryItems.length}
<div className="flex-1 flex flex-col overflow-hidden"> />
<TopHeader breadcrumb={headerInfo.breadcrumb} title={headerInfo.title} /> <div className="flex-1 flex flex-col overflow-hidden">
<main className="flex-1 overflow-auto"> <TopHeader breadcrumb={headerInfo.breadcrumb} title={headerInfo.title} />
{renderCurrentView()} <main className="flex-1 overflow-auto">
</main> {renderCurrentView()}
</main>
</div>
</div> </div>
</div> {/* Onboarding for first-time users */}
{/* Onboarding for first-time users */} <OnboardingDialog onComplete={() => handleViewChange('new-project')} />
<OnboardingDialog onComplete={() => handleViewChange('new-project')} /> {/* Pension onboarding for users without pensions */}
<PensionOnboardingDialog />
</UserLevelProvider>
</ThemeProvider> </ThemeProvider>
); );
}; };

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import KoreaMap from '../components/KoreaMap';
import { Button } from '../components/ui/button'; import { Button } from '../components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../components/ui/card'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../components/ui/card';
import { Badge } from '../components/ui/badge'; import { Badge } from '../components/ui/badge';
@ -39,7 +40,10 @@ import {
Share2, Share2,
Smartphone, Smartphone,
Youtube, Youtube,
Quote Quote,
PartyPopper,
Home,
MapPin
} from 'lucide-react'; } from 'lucide-react';
// Success case data - expanded // Success case data - expanded
@ -357,10 +361,50 @@ const FloatingHearts: React.FC = () => {
); );
}; };
interface RegionStat {
sido: string;
count: number;
}
interface MonthStat {
yearMonth: string;
label: string;
count: number;
}
interface FestivalMonthlyStats {
stats: MonthStat[];
total: number;
currentMonth: MonthStat;
nextMonth: MonthStat;
}
const LandingPage: React.FC = () => { const LandingPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const [currentCase, setCurrentCase] = useState(0); const [currentCase, setCurrentCase] = useState(0);
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const [festivalStats, setFestivalStats] = useState<{ stats: RegionStat[], total: number } | null>(null);
const [festivalMonthlyStats, setFestivalMonthlyStats] = useState<FestivalMonthlyStats | null>(null);
const [pensionStats, setPensionStats] = useState<{ stats: RegionStat[], total: number } | null>(null);
// Fetch festival and pension stats
useEffect(() => {
const fetchStats = async () => {
try {
const [festRes, festMonthRes, pensRes] = await Promise.all([
fetch('/api/festivals/stats/by-region'),
fetch('/api/festivals/stats/by-month'),
fetch('/api/pensions/stats/by-region')
]);
if (festRes.ok) setFestivalStats(await festRes.json());
if (festMonthRes.ok) setFestivalMonthlyStats(await festMonthRes.json());
if (pensRes.ok) setPensionStats(await pensRes.json());
} catch (e) {
console.error('Failed to fetch stats:', e);
}
};
fetchStats();
}, []);
useEffect(() => { useEffect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
@ -1185,6 +1229,156 @@ const LandingPage: React.FC = () => {
</div> </div>
</section> </section>
{/* ==================== FESTIVAL & PENSION STATS ==================== */}
{(festivalStats || pensionStats) && (
<section className="py-16 px-4 bg-muted/30">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-12">
<Badge variant="outline" className="mb-4">
<MapPin className="w-4 h-4 mr-2" />
&
</Badge>
<h2 className="text-3xl md:text-4xl font-bold mb-4">
</h2>
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
</p>
</div>
{/* Korea Map - Center */}
{festivalStats && (
<Card className="mb-8">
<CardHeader className="text-center">
<CardTitle className="flex items-center justify-center gap-2">
<MapPin className="w-5 h-5 text-orange-500" />
</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<KoreaMap
data={festivalStats.stats}
onRegionClick={(sido) => {
// 추후 지역 필터링 기능 연결
console.log('Selected region:', sido);
}}
/>
</CardContent>
</Card>
)}
{/* Stats Cards */}
<div className="grid md:grid-cols-2 gap-6">
{/* Festival Stats */}
{festivalStats && (
<Card className="overflow-hidden">
<CardHeader className="bg-gradient-to-r from-orange-500/10 to-amber-500/10 border-b">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-orange-500/20 flex items-center justify-center">
<PartyPopper className="w-6 h-6 text-orange-500" />
</div>
<div>
<CardTitle> </CardTitle>
<CardDescription> / </CardDescription>
</div>
</div>
<div className="text-right">
<p className="text-3xl font-bold text-orange-500">{festivalStats.total}</p>
<p className="text-xs text-muted-foreground"> </p>
</div>
</div>
</CardHeader>
<CardContent className="p-4 space-y-4">
{/* Monthly Stats */}
{festivalMonthlyStats && (
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-orange-500/10 border border-orange-500/20">
<div className="flex items-center gap-2 mb-1">
<Calendar className="w-4 h-4 text-orange-500" />
<span className="text-sm font-medium">{festivalMonthlyStats.currentMonth.label}</span>
</div>
<p className="text-2xl font-bold text-orange-600">{festivalMonthlyStats.currentMonth.count}</p>
<p className="text-xs text-muted-foreground"> </p>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<div className="flex items-center gap-2 mb-1">
<Calendar className="w-4 h-4 text-amber-500" />
<span className="text-sm font-medium">{festivalMonthlyStats.nextMonth.label}</span>
</div>
<p className="text-2xl font-bold text-amber-600">{festivalMonthlyStats.nextMonth.count}</p>
<p className="text-xs text-muted-foreground"> </p>
</div>
</div>
)}
<Button variant="outline" className="w-full gap-2" asChild>
<Link to="/login">
<Calendar className="w-4 h-4" />
</Link>
</Button>
</CardContent>
</Card>
)}
{/* Pension Stats */}
{pensionStats && (
<Card className="overflow-hidden">
<CardHeader className="bg-gradient-to-r from-green-500/10 to-emerald-500/10 border-b">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-green-500/20 flex items-center justify-center">
<Home className="w-6 h-6 text-green-500" />
</div>
<div>
<CardTitle> </CardTitle>
<CardDescription> </CardDescription>
</div>
</div>
<div className="text-right">
<p className="text-3xl font-bold text-green-500">{pensionStats.total.toLocaleString()}</p>
<p className="text-xs text-muted-foreground"> </p>
</div>
</div>
</CardHeader>
<CardContent className="p-4 space-y-4">
{/* Top Regions */}
<div>
<p className="text-xs text-muted-foreground mb-2"> TOP 6</p>
<div className="grid grid-cols-3 gap-2">
{pensionStats.stats.slice(0, 6).map((item) => (
<div key={item.sido} className="p-2 rounded-lg bg-muted/50 text-center">
<p className="font-bold text-lg">{item.count}</p>
<p className="text-xs text-muted-foreground">{item.sido}</p>
</div>
))}
</div>
</div>
<Button variant="outline" className="w-full gap-2" asChild>
<Link to="/register">
<Building className="w-4 h-4" />
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
<div className="mt-8 text-center">
<p className="text-sm text-muted-foreground">
* TourAPI
</p>
</div>
</div>
</section>
)}
{/* ==================== FINAL CTA ==================== */} {/* ==================== FINAL CTA ==================== */}
<section className="py-16 px-4"> <section className="py-16 px-4">
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">

View File

@ -2,6 +2,8 @@ import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { useUserLevel, LEVEL_INFO } from '../contexts/UserLevelContext';
import LevelSelector from '../components/settings/LevelSelector';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
import { Button } from '../components/ui/button'; import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input'; import { Input } from '../components/ui/input';
@ -36,6 +38,8 @@ import { Language, PLAN_CONFIG, PlanType } from '../../types';
const AccountView: React.FC = () => { const AccountView: React.FC = () => {
const { user, logout, token } = useAuth(); const { user, logout, token } = useAuth();
const { level } = useUserLevel();
const levelInfo = LEVEL_INFO[level];
// 플랜 정보 가져오기 // 플랜 정보 가져오기
const planType = (user?.plan_type || 'free') as PlanType; const planType = (user?.plan_type || 'free') as PlanType;
@ -126,6 +130,29 @@ const AccountView: React.FC = () => {
</div> </div>
)} )}
{/* User Level Section */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">{levelInfo.icon}</span>
</CardTitle>
<CardDescription>
</CardDescription>
</div>
<Badge variant="outline" className="text-lg px-3 py-1">
{levelInfo.icon} {levelInfo.name}
</Badge>
</div>
</CardHeader>
<CardContent>
<LevelSelector showTitle={false} />
</CardContent>
</Card>
{/* Profile Section */} {/* Profile Section */}
<Card> <Card>
<CardHeader> <CardHeader>

View File

@ -1,6 +1,7 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { GeneratedAssets } from '../../types'; import { useAuth } from '../contexts/AuthContext';
import { GeneratedAssets, NearbyFestival } from '../../types';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
import { Button } from '../components/ui/button'; import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge'; import { Badge } from '../components/ui/badge';
@ -17,7 +18,11 @@ import {
Sparkles, Sparkles,
TrendingUp, TrendingUp,
Music, Music,
Mic Mic,
PartyPopper,
MapPin,
ChevronRight,
Loader2
} from 'lucide-react'; } from 'lucide-react';
interface DashboardViewProps { interface DashboardViewProps {
@ -25,15 +30,79 @@ interface DashboardViewProps {
onCreateNew: () => void; onCreateNew: () => void;
onViewLibrary: () => void; onViewLibrary: () => void;
onSelectAsset?: (asset: GeneratedAssets) => void; onSelectAsset?: (asset: GeneratedAssets) => void;
onViewFestivals?: () => void;
} }
const DashboardView: React.FC<DashboardViewProps> = ({ const DashboardView: React.FC<DashboardViewProps> = ({
libraryItems, libraryItems,
onCreateNew, onCreateNew,
onViewLibrary, onViewLibrary,
onSelectAsset onSelectAsset,
onViewFestivals
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const { token } = useAuth();
// Festival state
const [upcomingFestivals, setUpcomingFestivals] = useState<NearbyFestival[]>([]);
const [loadingFestivals, setLoadingFestivals] = useState(true);
const [userRegion, setUserRegion] = useState<string>('');
// Fetch upcoming festivals based on user's pension region
useEffect(() => {
const fetchFestivals = async () => {
try {
// First, try to get user's default pension region
const pensionRes = await fetch('/api/profile/pensions', {
headers: { Authorization: `Bearer ${token}` }
});
let region = '';
if (pensionRes.ok) {
const pensions = await pensionRes.json();
const defaultPension = pensions.find((p: any) => p.is_default) || pensions[0];
if (defaultPension?.region) {
region = defaultPension.region;
setUserRegion(region);
} else if (defaultPension?.address) {
// Extract region from address
const parts = defaultPension.address.split(' ');
if (parts.length > 0) {
region = parts[0].replace(/도|시|특별시|광역시|특별자치시|특별자치도/g, '');
setUserRegion(region);
}
}
}
// Fetch festivals (with region filter if available)
const festivalUrl = region
? `/api/festivals?sido=${encodeURIComponent(region)}&limit=5`
: '/api/festivals?limit=5';
const festivalRes = await fetch(festivalUrl);
if (festivalRes.ok) {
const data = await festivalRes.json();
setUpcomingFestivals(data.festivals || []);
}
} catch (error) {
console.error('Failed to fetch festivals:', error);
} finally {
setLoadingFestivals(false);
}
};
if (token) {
fetchFestivals();
} else {
setLoadingFestivals(false);
}
}, [token]);
// Format festival date
const formatFestivalDate = (dateStr?: string) => {
if (!dateStr) return '';
return `${dateStr.slice(4, 6)}.${dateStr.slice(6, 8)}`;
};
// Stats calculations // Stats calculations
const totalVideos = libraryItems.length; const totalVideos = libraryItems.length;
@ -64,6 +133,43 @@ const DashboardView: React.FC<DashboardViewProps> = ({
</Button> </Button>
</div> </div>
{/* Festival Alert Banner - Show when there are ongoing festivals */}
{upcomingFestivals.length > 0 && upcomingFestivals.some(f => {
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const startDate = f.eventstartdate || '';
const endDate = f.eventenddate || '';
return (startDate && endDate && today >= startDate && today <= endDate);
}) && (
<Card className="bg-gradient-to-r from-orange-500 via-pink-500 to-purple-500 text-white border-0 overflow-hidden relative">
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiNmZmYiIGZpbGwtb3BhY2l0eT0iMC4xIj48cGF0aCBkPSJNMzYgMzRoLTJ2LTRoMnY0em0wLThoLTJ2LTRoMnY0em0tOCA4aC0ydi00aDJ2NHptMC04aC0ydi00aDJ2NHoiLz48L2c+PC9nPjwvc3ZnPg==')] opacity-30" />
<CardContent className="p-6 relative">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-white/20 backdrop-blur-sm flex items-center justify-center">
<PartyPopper className="w-7 h-7" />
</div>
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{userRegion ? `${userRegion} 지역 축제 진행 중!` : '지금 축제가 진행 중이에요!'}
<span className="animate-pulse">🎉</span>
</h3>
<p className="text-white/80 text-sm">
</p>
</div>
</div>
<Button
onClick={onCreateNew}
className="bg-white text-orange-600 hover:bg-white/90 font-bold shadow-lg"
>
<Sparkles className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card> <Card>
@ -166,6 +272,101 @@ const DashboardView: React.FC<DashboardViewProps> = ({
</Card> </Card>
</div> </div>
{/* Upcoming Festivals Widget */}
<Card className="border-orange-500/30 bg-gradient-to-r from-orange-500/5 to-transparent">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-orange-500/10 flex items-center justify-center">
<PartyPopper className="w-4 h-4 text-orange-500" />
</div>
<div>
<CardTitle className="text-base">
{userRegion ? `${userRegion} 지역 축제` : '다가오는 축제'}
</CardTitle>
<CardDescription className="text-xs">
</CardDescription>
</div>
</div>
{onViewFestivals && (
<Button variant="ghost" size="sm" onClick={onViewFestivals} className="text-orange-600 hover:text-orange-700 hover:bg-orange-500/10">
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
)}
</div>
</CardHeader>
<CardContent>
{loadingFestivals ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="w-5 h-5 animate-spin text-orange-500 mr-2" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : upcomingFestivals.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
<PartyPopper className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm"> </p>
{onViewFestivals && (
<Button variant="link" size="sm" onClick={onViewFestivals} className="text-orange-600 mt-2">
</Button>
)}
</div>
) : (
<div className="space-y-2">
{upcomingFestivals.slice(0, 4).map((festival) => (
<div
key={festival.id}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-orange-500/5 transition-colors cursor-pointer group"
onClick={onViewFestivals}
>
{festival.firstimage ? (
<img
src={festival.firstimage}
alt={festival.title}
className="w-12 h-12 rounded-lg object-cover shrink-0"
/>
) : (
<div className="w-12 h-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<PartyPopper className="w-5 h-5 text-orange-500" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate group-hover:text-orange-600 transition-colors">
{festival.title}
</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{festival.eventstartdate && (
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatFestivalDate(festival.eventstartdate)} ~ {formatFestivalDate(festival.eventenddate)}
</span>
)}
</div>
</div>
<Badge variant="outline" className="shrink-0 text-orange-600 border-orange-500/30">
<MapPin className="w-3 h-3 mr-1" />
{festival.addr1?.split(' ').slice(0, 2).join(' ') || ''}
</Badge>
</div>
))}
{upcomingFestivals.length > 4 && (
<Button
variant="ghost"
className="w-full text-orange-600 hover:text-orange-700 hover:bg-orange-500/10"
size="sm"
onClick={onViewFestivals}
>
+{upcomingFestivals.length - 4}
</Button>
)}
</div>
)}
</CardContent>
</Card>
<Separator /> <Separator />
{/* Recent Projects */} {/* Recent Projects */}

675
src/views/FestivalsView.tsx Normal file
View File

@ -0,0 +1,675 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Badge } from '../components/ui/badge';
import { Separator } from '../components/ui/separator';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '../components/ui/dialog';
import {
PartyPopper,
Search,
MapPin,
Calendar,
Phone,
ExternalLink,
ChevronLeft,
ChevronRight,
Loader2,
Home,
Navigation,
Filter,
X,
Image as ImageIcon,
Sparkles,
Wand2,
Video,
Grid3X3,
CalendarDays
} from 'lucide-react';
import { cn } from '../lib/utils';
interface Festival {
id: number;
content_id: string;
title: string;
addr1: string;
addr2: string;
sido: string;
sigungu: string;
mapx: number;
mapy: number;
event_start_date: string;
event_end_date: string;
first_image: string;
first_image2: string;
tel: string;
homepage: string;
overview: string;
view_count: number;
}
interface Pension {
id: number;
name: string;
address: string;
sido: string;
tel: string;
thumbnail: string;
}
interface Pagination {
total: number;
page: number;
limit: number;
totalPages: number;
}
const REGIONS = [
{ code: '', name: '전체 지역' },
{ code: '서울', name: '서울' },
{ code: '부산', name: '부산' },
{ code: '대구', name: '대구' },
{ code: '인천', name: '인천' },
{ code: '광주', name: '광주' },
{ code: '대전', name: '대전' },
{ code: '울산', name: '울산' },
{ code: '세종', name: '세종' },
{ code: '경기', name: '경기' },
{ code: '강원', name: '강원' },
{ code: '충북', name: '충북' },
{ code: '충남', name: '충남' },
{ code: '전북', name: '전북' },
{ code: '전남', name: '전남' },
{ code: '경북', name: '경북' },
{ code: '경남', name: '경남' },
{ code: '제주', name: '제주' },
];
interface FestivalsViewProps {
onCreateWithFestival?: (festival: Festival) => void;
}
const FestivalsView: React.FC<FestivalsViewProps> = ({ onCreateWithFestival }) => {
const { token } = useAuth();
// State
const [festivals, setFestivals] = useState<Festival[]>([]);
const [pagination, setPagination] = useState<Pagination | null>(null);
const [loading, setLoading] = useState(true);
const [selectedFestival, setSelectedFestival] = useState<Festival | null>(null);
const [nearbyPensions, setNearbyPensions] = useState<Pension[]>([]);
const [loadingPensions, setLoadingPensions] = useState(false);
// Filters
const [searchKeyword, setSearchKeyword] = useState('');
const [selectedRegion, setSelectedRegion] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [showFilters, setShowFilters] = useState(false);
const [viewMode, setViewMode] = useState<'grid' | 'calendar'>('grid');
const [calendarMonth, setCalendarMonth] = useState(new Date());
// Fetch festivals
const fetchFestivals = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: currentPage.toString(),
limit: '12',
});
if (selectedRegion) params.append('sido', selectedRegion);
if (searchKeyword) params.append('keyword', searchKeyword);
const res = await fetch(`/api/festivals?${params}`, {
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
});
if (res.ok) {
const data = await res.json();
setFestivals(data.festivals);
setPagination(data.pagination);
}
} catch (err) {
console.error('Failed to fetch festivals:', err);
} finally {
setLoading(false);
}
}, [token, currentPage, selectedRegion, searchKeyword]);
useEffect(() => {
fetchFestivals();
}, [fetchFestivals]);
// Fetch nearby pensions when festival selected
const fetchNearbyPensions = async (festivalId: number) => {
setLoadingPensions(true);
try {
const res = await fetch(`/api/festivals/${festivalId}/nearby-pensions?limit=6`, {
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
});
if (res.ok) {
const data = await res.json();
setNearbyPensions(data.pensions);
}
} catch (err) {
console.error('Failed to fetch nearby pensions:', err);
} finally {
setLoadingPensions(false);
}
};
const handleFestivalClick = (festival: Festival) => {
setSelectedFestival(festival);
setNearbyPensions([]);
fetchNearbyPensions(festival.id);
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setCurrentPage(1);
fetchFestivals();
};
const formatDate = (dateStr: string) => {
if (!dateStr || dateStr.length !== 8) return dateStr;
return `${dateStr.slice(0, 4)}.${dateStr.slice(4, 6)}.${dateStr.slice(6, 8)}`;
};
const getFestivalStatus = (startDate: string, endDate: string) => {
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
if (today < startDate) return { label: '예정', color: 'bg-blue-500' };
if (today > endDate) return { label: '종료', color: 'bg-gray-500' };
return { label: '진행중', color: 'bg-green-500' };
};
// Calendar helper functions
const getCalendarDays = () => {
const year = calendarMonth.getFullYear();
const month = calendarMonth.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startPadding = firstDay.getDay();
const days: (number | null)[] = [];
// Add padding for days before the first day
for (let i = 0; i < startPadding; i++) {
days.push(null);
}
// Add days of the month
for (let d = 1; d <= lastDay.getDate(); d++) {
days.push(d);
}
return days;
};
const getFestivalsForDay = (day: number) => {
if (!day) return [];
const year = calendarMonth.getFullYear();
const month = calendarMonth.getMonth() + 1;
const dateStr = `${year}${month.toString().padStart(2, '0')}${day.toString().padStart(2, '0')}`;
return festivals.filter(f => {
const start = f.event_start_date;
const end = f.event_end_date;
return dateStr >= start && dateStr <= end;
});
};
const navigateMonth = (direction: number) => {
setCalendarMonth(prev => new Date(prev.getFullYear(), prev.getMonth() + direction, 1));
};
return (
<div className="flex-1 overflow-auto p-6 bg-background">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<PartyPopper className="w-7 h-7 text-orange-500" />
</h1>
<p className="text-muted-foreground mt-1">
</p>
</div>
<div className="flex items-center gap-2">
{/* View Mode Toggle */}
<div className="flex items-center bg-muted rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={cn(
"p-2 rounded-md transition-colors",
viewMode === 'grid' ? "bg-background shadow-sm" : "hover:bg-background/50"
)}
title="그리드 뷰"
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('calendar')}
className={cn(
"p-2 rounded-md transition-colors",
viewMode === 'calendar' ? "bg-background shadow-sm" : "hover:bg-background/50"
)}
title="캘린더 뷰"
>
<CalendarDays className="w-4 h-4" />
</button>
</div>
{pagination && (
<Badge variant="secondary" className="text-sm">
{pagination.total}
</Badge>
)}
</div>
</div>
{/* Search & Filters */}
<Card>
<CardContent className="p-4">
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="축제명, 지역으로 검색..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="pl-9"
/>
</div>
<select
value={selectedRegion}
onChange={(e) => {
setSelectedRegion(e.target.value);
setCurrentPage(1);
}}
className="px-3 py-2 rounded-md border bg-background text-sm"
>
{REGIONS.map((region) => (
<option key={region.code} value={region.code}>
{region.name}
</option>
))}
</select>
<Button type="submit">
<Search className="w-4 h-4 mr-2" />
</Button>
</form>
</CardContent>
</Card>
{/* Loading */}
{loading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : festivals.length === 0 ? (
<Card className="p-12 text-center">
<PartyPopper className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-2"> </h3>
<p className="text-muted-foreground">
</p>
</Card>
) : (
<>
{/* Calendar View */}
{viewMode === 'calendar' && (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => navigateMonth(-1)}>
<ChevronLeft className="w-4 h-4" />
</Button>
<h3 className="text-lg font-bold">
{calendarMonth.getFullYear()} {calendarMonth.getMonth() + 1}
</h3>
<Button variant="ghost" size="sm" onClick={() => navigateMonth(1)}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Day headers */}
<div className="grid grid-cols-7 gap-1 mb-2">
{['일', '월', '화', '수', '목', '금', '토'].map((day, i) => (
<div
key={day}
className={cn(
"text-center text-sm font-medium py-2",
i === 0 && "text-red-500",
i === 6 && "text-blue-500"
)}
>
{day}
</div>
))}
</div>
{/* Calendar days */}
<div className="grid grid-cols-7 gap-1">
{getCalendarDays().map((day, idx) => {
const dayFestivals = day ? getFestivalsForDay(day) : [];
const isToday = day && new Date().getDate() === day &&
new Date().getMonth() === calendarMonth.getMonth() &&
new Date().getFullYear() === calendarMonth.getFullYear();
return (
<div
key={idx}
className={cn(
"min-h-[80px] p-1 border rounded-lg",
day ? "bg-card hover:bg-muted/50 cursor-pointer" : "bg-muted/30",
isToday && "ring-2 ring-primary"
)}
>
{day && (
<>
<span className={cn(
"text-sm font-medium",
idx % 7 === 0 && "text-red-500",
idx % 7 === 6 && "text-blue-500"
)}>
{day}
</span>
<div className="mt-1 space-y-0.5">
{dayFestivals.slice(0, 2).map(f => (
<div
key={f.id}
className="text-[10px] bg-orange-500/20 text-orange-700 dark:text-orange-400 rounded px-1 py-0.5 truncate cursor-pointer hover:bg-orange-500/30"
onClick={(e) => {
e.stopPropagation();
handleFestivalClick(f);
}}
title={f.title}
>
{f.title}
</div>
))}
{dayFestivals.length > 2 && (
<span className="text-[10px] text-muted-foreground">
+{dayFestivals.length - 2}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{/* Festivals Grid */}
{viewMode === 'grid' && (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
{festivals.map((festival) => {
const status = getFestivalStatus(festival.event_start_date, festival.event_end_date);
return (
<Card
key={festival.id}
className="overflow-hidden cursor-pointer hover:shadow-lg transition-all group"
onClick={() => handleFestivalClick(festival)}
>
{/* Image */}
<div className="aspect-[16/10] bg-muted relative overflow-hidden">
{festival.first_image ? (
<img
src={festival.first_image}
alt={festival.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageIcon className="w-12 h-12 text-muted-foreground/30" />
</div>
)}
<Badge className={cn("absolute top-2 right-2", status.color)}>
{status.label}
</Badge>
</div>
<CardContent className="p-4">
<h3 className="font-bold text-lg line-clamp-1 mb-2">
{festival.title}
</h3>
<div className="space-y-1.5 text-sm text-muted-foreground">
<p className="flex items-center gap-2">
<MapPin className="w-4 h-4 shrink-0" />
<span className="line-clamp-1">{festival.addr1}</span>
</p>
<p className="flex items-center gap-2">
<Calendar className="w-4 h-4 shrink-0" />
<span>
{formatDate(festival.event_start_date)} ~ {formatDate(festival.event_end_date)}
</span>
</p>
</div>
{festival.sido && (
<Badge variant="outline" className="mt-3">
{festival.sido}
</Badge>
)}
</CardContent>
</Card>
);
})}
</div>
)}
{/* Pagination - only show in grid view */}
{viewMode === 'grid' && pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm text-muted-foreground px-4">
{currentPage} / {pagination.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.min(pagination.totalPages, p + 1))}
disabled={currentPage === pagination.totalPages}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</>
)}
{/* Festival Detail Dialog */}
<Dialog open={!!selectedFestival} onOpenChange={() => setSelectedFestival(null)}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
{selectedFestival && (
<>
<DialogHeader>
<DialogTitle className="text-xl flex items-center gap-2">
<PartyPopper className="w-6 h-6 text-orange-500" />
{selectedFestival.title}
</DialogTitle>
<DialogDescription>
{selectedFestival.sido} {selectedFestival.sigungu}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Image */}
{selectedFestival.first_image && (
<div className="aspect-video rounded-lg overflow-hidden bg-muted">
<img
src={selectedFestival.first_image}
alt={selectedFestival.title}
className="w-full h-full object-cover"
/>
</div>
)}
{/* Info */}
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-3">
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-primary mt-0.5" />
<div>
<p className="font-medium"> </p>
<p className="text-sm text-muted-foreground">
{formatDate(selectedFestival.event_start_date)} ~ {formatDate(selectedFestival.event_end_date)}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<MapPin className="w-5 h-5 text-primary mt-0.5" />
<div>
<p className="font-medium"></p>
<p className="text-sm text-muted-foreground">
{selectedFestival.addr1} {selectedFestival.addr2}
</p>
</div>
</div>
{selectedFestival.tel && (
<div className="flex items-start gap-3">
<Phone className="w-5 h-5 text-primary mt-0.5" />
<div>
<p className="font-medium"></p>
<p className="text-sm text-muted-foreground">{selectedFestival.tel}</p>
</div>
</div>
)}
</div>
{/* Map Link */}
{selectedFestival.mapx && selectedFestival.mapy && (
<div className="flex flex-col gap-2">
<a
href={`https://map.kakao.com/link/map/${selectedFestival.title},${selectedFestival.mapy},${selectedFestival.mapx}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 p-3 rounded-lg bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20 transition-colors"
>
<Navigation className="w-5 h-5" />
</a>
<a
href={`https://map.naver.com/v5/search/${encodeURIComponent(selectedFestival.addr1)}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 p-3 rounded-lg bg-green-500/10 text-green-600 hover:bg-green-500/20 transition-colors"
>
<Navigation className="w-5 h-5" />
</a>
</div>
)}
</div>
{/* Create Content Button */}
{onCreateWithFestival && (
<div className="bg-gradient-to-r from-orange-500/10 via-pink-500/10 to-purple-500/10 rounded-xl p-4">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-orange-500 to-pink-500 flex items-center justify-center">
<Wand2 className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-bold"> </p>
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
<Button
onClick={() => {
onCreateWithFestival(selectedFestival);
setSelectedFestival(null);
}}
className="bg-gradient-to-r from-orange-500 to-pink-500 hover:from-orange-600 hover:to-pink-600 text-white"
>
<Sparkles className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
)}
<Separator />
{/* Nearby Pensions */}
<div>
<h3 className="font-bold text-lg flex items-center gap-2 mb-4">
<Home className="w-5 h-5 text-green-500" />
</h3>
{loadingPensions ? (
<div className="flex items-center justify-center h-24">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
) : nearbyPensions.length === 0 ? (
<p className="text-muted-foreground text-center py-6">
</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{nearbyPensions.map((pension) => (
<Card key={pension.id} className="p-3">
<div className="aspect-[4/3] rounded-md bg-muted mb-2 overflow-hidden">
{pension.thumbnail ? (
<img
src={pension.thumbnail}
alt={pension.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Home className="w-8 h-8 text-muted-foreground/30" />
</div>
)}
</div>
<h4 className="font-medium text-sm line-clamp-1">{pension.name}</h4>
<p className="text-xs text-muted-foreground line-clamp-1">
{pension.address}
</p>
</Card>
))}
</div>
)}
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
</div>
</div>
);
};
export default FestivalsView;

View File

@ -23,7 +23,10 @@ import {
MoreVertical, MoreVertical,
Eye, Eye,
ExternalLink, ExternalLink,
X X,
Youtube,
Instagram,
Loader2
} from 'lucide-react'; } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
@ -52,10 +55,137 @@ const LibraryView: React.FC<LibraryViewProps> = ({
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [showPlayer, setShowPlayer] = useState(false); const [showPlayer, setShowPlayer] = useState(false);
// 업로드 상태
const [uploadingId, setUploadingId] = useState<number | null>(null);
const [uploadPlatform, setUploadPlatform] = useState<'youtube' | 'instagram' | null>(null);
const filteredItems = items.filter(item => const filteredItems = items.filter(item =>
item.businessName.toLowerCase().includes(searchQuery.toLowerCase()) item.businessName.toLowerCase().includes(searchQuery.toLowerCase())
); );
// YouTube 업로드
const handleYoutubeUpload = async (e: React.MouseEvent, asset: GeneratedAssets) => {
e.stopPropagation();
if (!asset.id || !asset.finalVideoPath) {
alert('업로드할 영상이 없습니다. 먼저 영상을 생성해주세요.');
return;
}
setUploadingId(asset.id);
setUploadPlatform('youtube');
try {
// YouTube 연결 상태 확인
const connRes = await fetch('/api/youtube/connection', {
headers: { 'Authorization': token ? `Bearer ${token}` : '' }
});
const connData = await connRes.json();
if (!connData.connected) {
if (confirm('YouTube 계정이 연결되지 않았습니다. 설정 페이지를 새 탭에서 여시겠습니까?')) {
window.open('/settings', '_blank');
}
return;
}
// 업로드 실행
const response = await fetch('/api/youtube/my-upload', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify({
videoPath: asset.finalVideoPath,
seoData: {
title: `${asset.businessName} - AI Marketing Video`,
description: asset.description || asset.adCopy?.join('\n') || '',
tags: ['펜션', '숙소', '여행', 'AI마케팅', 'CastAD']
},
historyId: asset.id
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'YouTube 업로드 실패');
}
alert(`YouTube 업로드 성공!\n${data.youtubeUrl || data.url}`);
if (data.youtubeUrl || data.url) {
window.open(data.youtubeUrl || data.url, '_blank');
}
} catch (error: any) {
console.error('YouTube 업로드 오류:', error);
alert(`YouTube 업로드 실패: ${error.message}`);
} finally {
setUploadingId(null);
setUploadPlatform(null);
}
};
// Instagram 업로드
const handleInstagramUpload = async (e: React.MouseEvent, asset: GeneratedAssets) => {
e.stopPropagation();
if (!asset.id || !asset.finalVideoPath) {
alert('업로드할 영상이 없습니다. 먼저 영상을 생성해주세요.');
return;
}
setUploadingId(asset.id);
setUploadPlatform('instagram');
try {
// Instagram 연결 상태 확인
const statusRes = await fetch('/api/instagram/status', {
headers: { 'Authorization': token ? `Bearer ${token}` : '' }
});
const statusData = await statusRes.json();
if (!statusData.connected) {
if (confirm('Instagram 계정이 연결되지 않았습니다. 설정 페이지를 새 탭에서 여시겠습니까?')) {
window.open('/settings', '_blank');
}
return;
}
// 캡션 및 해시태그 생성
const caption = asset.adCopy?.join('\n') || asset.businessName || '';
const hashtags = `#${asset.businessName?.replace(/\s+/g, '')} #펜션 #숙소 #여행 #힐링 #휴가 #AI마케팅 #CaStAD`;
// 업로드 실행
const response = await fetch('/api/instagram/upload', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify({
history_id: asset.id,
caption: caption,
hashtags: hashtags
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Instagram 업로드 실패');
}
alert('Instagram Reels 업로드 성공!');
} catch (error: any) {
console.error('Instagram 업로드 오류:', error);
alert(`Instagram 업로드 실패: ${error.message}`);
} finally {
setUploadingId(null);
setUploadPlatform(null);
}
};
const handleDirectDownload = async (e: React.MouseEvent, url: string, filename: string) => { const handleDirectDownload = async (e: React.MouseEvent, url: string, filename: string) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -244,12 +374,39 @@ const LibraryView: React.FC<LibraryViewProps> = ({
{t('libViewDetail')} {t('libViewDetail')}
</DropdownMenuItem> </DropdownMenuItem>
{asset.finalVideoPath && ( {asset.finalVideoPath && (
<DropdownMenuItem <>
onClick={(e) => handleDirectDownload(e as any, asset.finalVideoPath!, `CastAD_${asset.businessName}.mp4`)} <DropdownMenuItem
> onClick={(e) => handleDirectDownload(e as any, asset.finalVideoPath!, `CastAD_${asset.businessName}.mp4`)}
<Download className="w-4 h-4 mr-2" /> >
{t('libDownload')} <Download className="w-4 h-4 mr-2" />
</DropdownMenuItem> {t('libDownload')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleYoutubeUpload(e, asset)}
disabled={uploadingId === asset.id}
className="text-red-600"
>
{uploadingId === asset.id && uploadPlatform === 'youtube' ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Youtube className="w-4 h-4 mr-2" />
)}
YouTube
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => handleInstagramUpload(e, asset)}
disabled={uploadingId === asset.id}
className="text-pink-600"
>
{uploadingId === asset.id && uploadPlatform === 'instagram' ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Instagram className="w-4 h-4 mr-2" />
)}
Instagram
</DropdownMenuItem>
</>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive"> <DropdownMenuItem className="text-destructive">

File diff suppressed because it is too large Load Diff

View File

@ -37,9 +37,25 @@ import {
Video, Video,
Clock, Clock,
Heart, Heart,
Users Users,
PartyPopper,
Calendar,
MapPin,
ChevronRight,
Zap,
Settings2,
Play,
Image as ImageIcon
} from 'lucide-react'; } from 'lucide-react';
import { PensionProfile, PlanType, PLAN_CONFIG } from '../../types'; import { Switch } from '../components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../components/ui/select';
import { PensionProfile, PlanType, PLAN_CONFIG, NearbyFestival } from '../../types';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
interface UserPlan { interface UserPlan {
@ -74,6 +90,19 @@ interface AnalyticsSummary {
pensions: PensionSummary[]; pensions: PensionSummary[];
} }
interface AutoGenerationSettings {
pension_id: number;
enabled: boolean;
generation_time: string;
image_mode: 'priority_first' | 'random' | 'all';
random_count: number;
auto_upload_youtube: boolean;
auto_upload_instagram: boolean;
auto_upload_tiktok: boolean;
last_generated_at: string | null;
next_scheduled_at: string | null;
}
const PensionsView: React.FC = () => { const PensionsView: React.FC = () => {
const { token } = useAuth(); const { token } = useAuth();
const { t } = useLanguage(); const { t } = useLanguage();
@ -90,6 +119,16 @@ const PensionsView: React.FC = () => {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Nearby festivals state (per pension)
const [pensionFestivals, setPensionFestivals] = useState<Record<number, NearbyFestival[]>>({});
const [loadingFestivals, setLoadingFestivals] = useState<Record<number, boolean>>({});
// Auto-generation state
const [autoGenSettings, setAutoGenSettings] = useState<Record<number, AutoGenerationSettings>>({});
const [isAutoGenDialogOpen, setIsAutoGenDialogOpen] = useState(false);
const [autoGenFormData, setAutoGenFormData] = useState<AutoGenerationSettings | null>(null);
const [savingAutoGen, setSavingAutoGen] = useState(false);
// Form state // Form state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
brand_name: '', brand_name: '',
@ -106,6 +145,186 @@ const PensionsView: React.FC = () => {
fetchData(); fetchData();
}, [token]); }, [token]);
// Fetch nearby festivals and auto-gen settings for all pensions after pensions are loaded
useEffect(() => {
if (pensions.length > 0) {
pensions.forEach(pension => {
fetchNearbyFestivals(pension);
fetchAutoGenSettings(pension.id);
});
}
}, [pensions]);
// Fetch nearby festivals for a specific pension
const fetchNearbyFestivals = async (pension: PensionProfile) => {
if (!pension.region && !pension.address) return;
if (pensionFestivals[pension.id]) return; // Already loaded
setLoadingFestivals(prev => ({ ...prev, [pension.id]: true }));
try {
// Extract sido from region or address
let sido = pension.region || '';
if (!sido && pension.address) {
const parts = pension.address.split(' ');
if (parts.length > 0) {
sido = parts[0].replace(/도|시|특별시|광역시|특별자치시|특별자치도/g, '');
}
}
const res = await fetch(`/api/festivals?sido=${encodeURIComponent(sido)}&limit=3`);
if (res.ok) {
const data = await res.json();
setPensionFestivals(prev => ({
...prev,
[pension.id]: data.festivals || []
}));
}
} catch (err) {
console.error('Failed to fetch festivals for pension:', err);
} finally {
setLoadingFestivals(prev => ({ ...prev, [pension.id]: false }));
}
};
// Format festival date
const formatFestivalDate = (dateStr?: string) => {
if (!dateStr) return '';
return `${dateStr.slice(4, 6)}.${dateStr.slice(6, 8)}`;
};
// Fetch auto-generation settings for a pension
const fetchAutoGenSettings = async (pensionId: number) => {
try {
const res = await fetch(`/api/profile/pension/${pensionId}/auto-generation`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setAutoGenSettings(prev => ({ ...prev, [pensionId]: data }));
}
} catch (err) {
console.error('Failed to fetch auto-gen settings:', err);
}
};
// Open auto-generation settings dialog
const handleOpenAutoGenDialog = async (pension: PensionProfile) => {
setSelectedPension(pension);
// 기존 설정 가져오기 또는 기본값 사용
let settings = autoGenSettings[pension.id];
if (!settings) {
try {
const res = await fetch(`/api/profile/pension/${pension.id}/auto-generation`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
settings = await res.json();
setAutoGenSettings(prev => ({ ...prev, [pension.id]: settings! }));
}
} catch (err) {
console.error('Failed to fetch auto-gen settings:', err);
}
}
setAutoGenFormData(settings || {
pension_id: pension.id,
enabled: false,
generation_time: '09:00',
image_mode: 'priority_first',
random_count: 10,
auto_upload_youtube: true,
auto_upload_instagram: false,
auto_upload_tiktok: false,
last_generated_at: null,
next_scheduled_at: null
});
setIsAutoGenDialogOpen(true);
};
// Save auto-generation settings
const handleSaveAutoGenSettings = async () => {
if (!selectedPension || !autoGenFormData) return;
setSavingAutoGen(true);
try {
const res = await fetch(`/api/profile/pension/${selectedPension.id}/auto-generation`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(autoGenFormData)
});
const data = await res.json();
if (res.ok) {
setAutoGenSettings(prev => ({
...prev,
[selectedPension.id]: { ...autoGenFormData, next_scheduled_at: data.next_scheduled_at }
}));
setMessage({ type: 'success', text: autoGenFormData.enabled ? '자동 생성이 활성화되었습니다.' : '자동 생성 설정이 저장되었습니다.' });
setIsAutoGenDialogOpen(false);
} else {
throw new Error(data.error || '저장에 실패했습니다.');
}
} catch (err: any) {
setMessage({ type: 'error', text: err.message });
} finally {
setSavingAutoGen(false);
setTimeout(() => setMessage(null), 3000);
}
};
// Quick toggle auto-generation
const handleToggleAutoGen = async (pension: PensionProfile) => {
const currentSettings = autoGenSettings[pension.id];
const newEnabled = !currentSettings?.enabled;
try {
const res = await fetch(`/api/profile/pension/${pension.id}/auto-generation`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
...(currentSettings || {
generation_time: '09:00',
image_mode: 'priority_first',
random_count: 10,
auto_upload_youtube: true,
auto_upload_instagram: false,
auto_upload_tiktok: false
}),
enabled: newEnabled
})
});
const data = await res.json();
if (res.ok) {
setAutoGenSettings(prev => ({
...prev,
[pension.id]: {
...(prev[pension.id] || {}),
enabled: newEnabled,
next_scheduled_at: data.next_scheduled_at
} as AutoGenerationSettings
}));
setMessage({
type: 'success',
text: newEnabled ? '일일 자동 생성이 활성화되었습니다.' : '일일 자동 생성이 비활성화되었습니다.'
});
}
} catch (err) {
console.error('Failed to toggle auto-gen:', err);
}
setTimeout(() => setMessage(null), 3000);
};
const fetchData = async () => { const fetchData = async () => {
if (!token) return; if (!token) return;
setLoading(true); setLoading(true);
@ -499,6 +718,45 @@ const PensionsView: React.FC = () => {
)} )}
</div> </div>
{/* Auto Generation Status */}
<div className={cn(
"flex items-center justify-between p-3 rounded-lg",
autoGenSettings[pension.id]?.enabled
? "bg-amber-500/10 border border-amber-500/30"
: "bg-muted/50"
)}>
<div className="flex items-center gap-2">
<Zap className={cn(
"w-5 h-5",
autoGenSettings[pension.id]?.enabled ? "text-amber-500" : "text-muted-foreground"
)} />
<div>
<span className="text-sm font-medium">
{autoGenSettings[pension.id]?.enabled ? '자동 생성 ON' : '자동 생성 OFF'}
</span>
{autoGenSettings[pension.id]?.enabled && autoGenSettings[pension.id]?.generation_time && (
<p className="text-xs text-muted-foreground">
{autoGenSettings[pension.id].generation_time}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Switch
checked={autoGenSettings[pension.id]?.enabled || false}
onCheckedChange={() => handleToggleAutoGen(pension)}
/>
<Button
size="sm"
variant="ghost"
onClick={() => handleOpenAutoGenDialog(pension)}
className="w-8 h-8 p-0"
>
<Settings2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* Analytics Preview */} {/* Analytics Preview */}
{analytics && analytics.total_videos > 0 && ( {analytics && analytics.total_videos > 0 && (
<div className="grid grid-cols-3 gap-2 text-center"> <div className="grid grid-cols-3 gap-2 text-center">
@ -517,6 +775,42 @@ const PensionsView: React.FC = () => {
</div> </div>
)} )}
{/* Nearby Festivals */}
{(pensionFestivals[pension.id]?.length > 0 || loadingFestivals[pension.id]) && (
<div className="bg-orange-500/5 rounded-lg p-3 border border-orange-500/20">
<div className="flex items-center gap-2 mb-2">
<PartyPopper className="w-4 h-4 text-orange-500" />
<span className="text-sm font-medium"> </span>
</div>
{loadingFestivals[pension.id] ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
...
</div>
) : (
<div className="space-y-1.5">
{pensionFestivals[pension.id]?.slice(0, 2).map((festival) => (
<div key={festival.id} className="flex items-center justify-between text-xs">
<span className="font-medium text-foreground truncate max-w-[150px]">
{festival.title}
</span>
{festival.eventstartdate && (
<span className="text-orange-600 shrink-0">
{formatFestivalDate(festival.eventstartdate)}~
</span>
)}
</div>
))}
{(pensionFestivals[pension.id]?.length || 0) > 2 && (
<p className="text-xs text-muted-foreground">
+{pensionFestivals[pension.id].length - 2}
</p>
)}
</div>
)}
</div>
)}
{/* Links */} {/* Links */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{pension.homepage_url && ( {pension.homepage_url && (
@ -778,6 +1072,202 @@ const PensionsView: React.FC = () => {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Auto Generation Settings Dialog */}
<Dialog open={isAutoGenDialogOpen} onOpenChange={setIsAutoGenDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Zap className="w-5 h-5 text-amber-500" />
</DialogTitle>
<DialogDescription>
{selectedPension?.brand_name} -
</DialogDescription>
</DialogHeader>
{autoGenFormData && (
<div className="space-y-6 py-4">
{/* Enable Toggle */}
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-3">
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center",
autoGenFormData.enabled ? "bg-amber-500/20" : "bg-muted"
)}>
<Zap className={cn(
"w-5 h-5",
autoGenFormData.enabled ? "text-amber-500" : "text-muted-foreground"
)} />
</div>
<div>
<p className="font-medium"> </p>
<p className="text-xs text-muted-foreground">
{autoGenFormData.enabled ? '매일 영상 자동 생성' : '비활성화됨'}
</p>
</div>
</div>
<Switch
checked={autoGenFormData.enabled}
onCheckedChange={(checked) =>
setAutoGenFormData(prev => prev ? { ...prev, enabled: checked } : prev)
}
/>
</div>
{/* Settings (shown when enabled) */}
{autoGenFormData.enabled && (
<>
<Separator />
{/* Generation Time */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Clock className="w-4 h-4" />
</Label>
<Select
value={autoGenFormData.generation_time}
onValueChange={(value) =>
setAutoGenFormData(prev => prev ? { ...prev, generation_time: value } : prev)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="06:00"> 6</SelectItem>
<SelectItem value="09:00"> 9</SelectItem>
<SelectItem value="12:00"> 12</SelectItem>
<SelectItem value="15:00"> 3</SelectItem>
<SelectItem value="18:00"> 6</SelectItem>
<SelectItem value="21:00"> 9</SelectItem>
</SelectContent>
</Select>
</div>
{/* Image Mode */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<ImageIcon className="w-4 h-4" />
</Label>
<Select
value={autoGenFormData.image_mode}
onValueChange={(value: 'priority_first' | 'random' | 'all') =>
setAutoGenFormData(prev => prev ? { ...prev, image_mode: value } : prev)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="priority_first">
<div className="flex items-center gap-2">
<Star className="w-3 h-3 text-amber-500" />
</div>
</SelectItem>
<SelectItem value="random"> </SelectItem>
<SelectItem value="all"> </SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{autoGenFormData.image_mode === 'priority_first'
? '수동으로 업로드한 이미지를 우선 사용합니다'
: autoGenFormData.image_mode === 'random'
? '저장된 이미지 중 랜덤으로 선택합니다'
: '모든 이미지를 순환하며 사용합니다'}
</p>
</div>
{/* Image Count (for random mode) */}
{autoGenFormData.image_mode !== 'all' && (
<div className="space-y-2">
<Label> </Label>
<Select
value={String(autoGenFormData.random_count)}
onValueChange={(value) =>
setAutoGenFormData(prev => prev ? { ...prev, random_count: parseInt(value) } : prev)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="15">15</SelectItem>
<SelectItem value="20">20</SelectItem>
</SelectContent>
</Select>
</div>
)}
<Separator />
{/* Auto Upload Options */}
<div className="space-y-3">
<Label> </Label>
<div className="space-y-2">
<div className="flex items-center justify-between p-2 rounded-lg bg-muted/30">
<div className="flex items-center gap-2">
<Youtube className="w-4 h-4 text-red-500" />
<span className="text-sm">YouTube</span>
</div>
<Switch
checked={autoGenFormData.auto_upload_youtube}
onCheckedChange={(checked) =>
setAutoGenFormData(prev => prev ? { ...prev, auto_upload_youtube: checked } : prev)
}
/>
</div>
<div className="flex items-center justify-between p-2 rounded-lg bg-muted/30">
<div className="flex items-center gap-2">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
<span className="text-sm">Instagram</span>
</div>
<Switch
checked={autoGenFormData.auto_upload_instagram}
onCheckedChange={(checked) =>
setAutoGenFormData(prev => prev ? { ...prev, auto_upload_instagram: checked } : prev)
}
/>
</div>
</div>
</div>
{/* Next Schedule Info */}
{autoGenFormData.next_scheduled_at && (
<div className="p-3 bg-amber-500/10 rounded-lg border border-amber-500/30">
<p className="text-sm">
<span className="font-medium"> :</span>{' '}
{new Date(autoGenFormData.next_scheduled_at).toLocaleString('ko-KR')}
</p>
</div>
)}
</>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setIsAutoGenDialogOpen(false)}>
</Button>
<Button
onClick={handleSaveAutoGenSettings}
disabled={savingAutoGen}
className="gap-2"
>
{savingAutoGen && <Loader2 className="w-4 h-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
</div> </div>
); );

View File

@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { useUserLevel, LEVEL_INFO } from '../contexts/UserLevelContext';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import LevelSelector from '../components/settings/LevelSelector';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
import { Button } from '../components/ui/button'; import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input'; import { Input } from '../components/ui/input';
@ -48,6 +50,7 @@ import {
Save, Save,
RefreshCw, RefreshCw,
Trash2, Trash2,
Sliders,
Edit2, Edit2,
Star, Star,
StarOff StarOff
@ -625,7 +628,11 @@ const SettingsView: React.FC = () => {
{/* Tabs */} {/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="level" className="flex items-center gap-2">
<Sliders className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="pension" className="flex items-center gap-2"> <TabsTrigger value="pension" className="flex items-center gap-2">
<Building2 className="w-4 h-4" /> <Building2 className="w-4 h-4" />
{t('settingsPensionTab')} ({pensions.length}) {t('settingsPensionTab')} ({pensions.length})
@ -640,6 +647,24 @@ const SettingsView: React.FC = () => {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{/* Level Selection Tab */}
<TabsContent value="level" className="space-y-6 mt-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sliders className="w-5 h-5" />
</CardTitle>
<CardDescription>
. .
</CardDescription>
</CardHeader>
<CardContent>
<LevelSelector />
</CardContent>
</Card>
</TabsContent>
{/* Pension Profile Tab */} {/* Pension Profile Tab */}
<TabsContent value="pension" className="space-y-6 mt-6"> <TabsContent value="pension" className="space-y-6 mt-6">
{/* Add New Pension Button */} {/* Add New Pension Button */}

3
start.sh Normal file → Executable file
View File

@ -85,6 +85,9 @@ if lsof -i:$INSTAGRAM_SERVICE_PORT >/dev/null 2>&1; then
else else
if [ -f "server/instagram/instagram_service.py" ]; then if [ -f "server/instagram/instagram_service.py" ]; then
log "Instagram 서비스 시작 중..." log "Instagram 서비스 시작 중..."
# Python 캐시 삭제 (코드 변경 반영)
find server/instagram -name "*.pyc" -delete 2>/dev/null
find server/instagram -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null
# Python 의존성 확인 # Python 의존성 확인
if ! python3 -c "import instagrapi" 2>/dev/null; then if ! python3 -c "import instagrapi" 2>/dev/null; then
warn "Instagram Python 의존성 설치 중..." warn "Instagram Python 의존성 설치 중..."

1365
startserver.sh Normal file → Executable file

File diff suppressed because it is too large Load Diff

0
test_api.sh Normal file → Executable file
View File

131
types.ts
View File

@ -1,4 +1,49 @@
// 사용자 경험 레벨
export type UserLevel = 'beginner' | 'intermediate' | 'pro';
// 레벨별 기능 플래그
export interface FeatureFlags {
// 옵션 선택기 표시
showLanguageSelector: boolean;
showMusicGenreSelector: boolean;
showAudioModeSelector: boolean;
showMusicDurationSelector: boolean;
showTextEffectSelector: boolean;
showTransitionSelector: boolean;
showAspectRatioSelector: boolean;
showVisualStyleSelector: boolean;
showTtsConfig: boolean;
// 고급 기능
showFestivalIntegration: boolean;
showAiImageGeneration: boolean;
showCustomCss: boolean;
showSeoEditor: boolean;
showThumbnailSelector: boolean;
// 메뉴 표시
showAssetLibrary: boolean;
showPensionManagement: boolean;
showAdvancedSettings: boolean;
showFestivalMenu: boolean;
// 자동화
autoUpload: boolean;
showUploadOptions: boolean;
showScheduleSettings: boolean;
}
// 자동 생성 설정
export interface AutoGenerationSettings {
enabled: boolean;
dayOfWeek: number; // 0=일, 1=월, ..., 6=토
timeOfDay: string; // "09:00"
pensionId?: number;
lastGeneratedAt?: string;
nextScheduledAt?: string;
}
export type Language = 'KO' | 'EN' | 'JP' | 'CN' | 'TH' | 'VN'; export type Language = 'KO' | 'EN' | 'JP' | 'CN' | 'TH' | 'VN';
// 펜션 카테고리 타입 // 펜션 카테고리 타입
@ -65,6 +110,14 @@ export interface BusinessInfo {
language: Language; language: Language;
useAiImages: boolean; // AI 이미지 생성 허용 여부 useAiImages: boolean; // AI 이미지 생성 허용 여부
pensionCategories?: PensionCategory[]; // 펜션 카테고리 (복수 선택) pensionCategories?: PensionCategory[]; // 펜션 카테고리 (복수 선택)
nearbyFestivals?: { // 근처 축제 정보 (콘텐츠 생성에 활용)
id: number;
title: string;
eventstartdate?: string;
eventenddate?: string;
addr1?: string;
distance?: number;
}[];
} }
export interface GeneratedAssets { export interface GeneratedAssets {
@ -268,3 +321,81 @@ export interface StorageStats {
videoSize: number; // bytes videoSize: number; // bytes
} }
// 축제 정보 타입
export interface NearbyFestival {
id: number;
title: string;
addr1?: string;
addr2?: string;
eventstartdate?: string;
eventenddate?: string;
tel?: string;
firstimage?: string;
firstimage2?: string;
mapx?: number;
mapy?: number;
distance?: number; // km 단위 거리
overview?: string;
}
// Business DNA - 펜션/숙소의 브랜드 DNA 분석 결과
export interface BusinessDNA {
// 기본 정보
name: string;
tagline?: string; // 한 줄 슬로건
// 톤 & 매너
toneAndManner: {
primary: string; // 예: "Warm & Cozy", "Luxurious & Elegant", "Modern & Minimal"
secondary?: string;
description: string; // 상세 설명
};
// 타겟 고객
targetCustomers: {
primary: string; // 예: "Young Couples", "Families with Kids", "Solo Travelers"
secondary?: string[];
ageRange?: string; // 예: "25-35"
characteristics?: string[]; // 특성들
};
// 브랜드 컬러
brandColors: {
primary: string; // HEX 코드, 예: "#4A90A4"
secondary?: string;
accent?: string;
palette?: string[]; // 전체 팔레트
mood: string; // 컬러가 주는 분위기
};
// 키워드 & 해시태그
keywords: {
primary: string[]; // 핵심 키워드 3-5개
secondary?: string[]; // 부가 키워드
hashtags?: string[]; // 추천 해시태그
};
// 시각적 스타일 (NEW)
visualStyle: {
interior: string; // 예: "Scandinavian Minimalist", "Korean Traditional Hanok", "Industrial Loft"
exterior: string; // 예: "Mountain Lodge", "Seaside Villa", "Forest Cabin"
atmosphere: string; // 예: "Serene & Peaceful", "Vibrant & Energetic", "Romantic & Intimate"
photoStyle: string; // 사진 스타일: "Warm Natural Light", "Moody & Dramatic", "Bright & Airy"
suggestedFilters?: string[]; // 추천 필터/톤: ["Warm", "High Contrast", "Muted Colors"]
};
// 차별화 포인트
uniqueSellingPoints: string[];
// 분위기/무드
mood: {
primary: string;
emotions: string[]; // 고객이 느낄 감정들
};
// 메타 정보
analyzedAt: string;
confidence: number; // 0-1 분석 신뢰도
sources?: string[]; // 분석에 사용된 소스들
}