first commit

main
bluebamus 2025-12-15 13:51:06 +09:00
commit 086d898b68
154 changed files with 68619 additions and 0 deletions

30
Dockerfile Normal file
View File

@ -0,0 +1,30 @@
FROM ubuntu:24.04
WORKDIR /app
ENV DEBIAN_FRONTEND=noninteractive
ENV NODE_ENV=production
# 시스템 패키지 설치
RUN apt-get update && apt-get install -y \
curl \
wget \
gnupg \
lsb-release \
apt-transport-https \
ca-certificates \
git \
build-essential \
python3 \
&& rm -rf /var/lib/apt/lists/*
# Node.js 및 npm 설치
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*
# 포트 개방
EXPOSE 3000 3001
# 컨테이너 실행 상태 유지
CMD ["tail", "-f", "/dev/null"]

193
castad-data/.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_* (비용 분석)

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 (사용자 토큰, 자동생성)

56
castad-data/.gitignore vendored Normal file
View File

@ -0,0 +1,56 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tar.gz
server/downloads/*
!server/downloads/.gitkeep
server/database.sqlite
# Environment
.env
.env.*
!.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
__pycache__/
*.py[cod]
*$py.class
venv/
*.egg-info/
# Data files
data/

99
castad-data/App.tsx Normal file
View File

@ -0,0 +1,99 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './src/contexts/AuthContext';
import { LanguageProvider } from './src/contexts/LanguageContext';
import { ThemeProvider } from './src/contexts/ThemeContext';
import Navbar from './components/Navbar';
import CastADApp from './src/pages/CastADApp';
import LoginPage from './src/pages/LoginPage';
import RegisterPage from './src/pages/RegisterPage';
import ForgotPasswordPage from './src/pages/ForgotPasswordPage';
import ResetPasswordPage from './src/pages/ResetPasswordPage';
import VerifyEmailPage from './src/pages/VerifyEmailPage';
import OAuthCallbackPage from './src/pages/OAuthCallbackPage';
import AdminDashboard from './src/pages/AdminDashboard';
import LandingPage from './src/pages/LandingPage';
import BrandPage from './src/pages/BrandPage';
import CreditsPage from './src/pages/CreditsPage';
import './src/styles/globals.css';
import './src/styles/text-effects.css';
import './src/styles/animations.css';
// 홈 라우트: 로그인 여부에 따라 랜딩 또는 SaaS 앱 표시
const HomeRoute: React.FC = () => {
const { user } = useAuth();
return user ? <CastADApp /> : <LandingPage />;
};
// SaaS 앱 보호 라우트: 로그인 필요
const ProtectedAppRoute: React.FC = () => {
const { user } = useAuth();
return user ? <CastADApp /> : <Navigate to="/login" />;
};
// 랜딩 페이지용 라우트 (Navbar 포함)
const PublicRoutes: React.FC = () => {
return (
<>
<Navbar />
<div className="pt-16 min-h-screen bg-background text-foreground font-sans">
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/oauth/callback" element={<OAuthCallbackPage />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/brand" element={<BrandPage />} />
<Route path="/" element={<LandingPage />} />
<Route path="/app/*" element={<Navigate to="/login" />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</div>
</>
);
};
const AppRoutes: React.FC = () => {
const { user } = useAuth();
// 로그인된 사용자는 SaaS 앱으로 (Navbar 없음, Sidebar 사용)
if (user) {
return (
<Routes>
<Route path="/login" element={<Navigate to="/app" />} />
<Route path="/register" element={<Navigate to="/app" />} />
<Route path="/forgot-password" element={<Navigate to="/app" />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/oauth/callback" element={<OAuthCallbackPage />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/brand" element={<BrandPage />} />
<Route path="/credits" element={<CreditsPage />} />
<Route path="/app/*" element={<CastADApp />} />
<Route path="/" element={<Navigate to="/app" />} />
<Route path="*" element={<Navigate to="/app" />} />
</Routes>
);
}
// 로그인되지 않은 사용자는 공개 페이지로
return <PublicRoutes />;
};
const App: React.FC = () => {
return (
<AuthProvider>
<LanguageProvider>
<ThemeProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</ThemeProvider>
</LanguageProvider>
</AuthProvider>
);
};
export default App;

75
castad-data/CLAUDE.md Normal file
View File

@ -0,0 +1,75 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
CastAD (formerly ADo4) is an AI-powered marketing video generation platform for pension (accommodation) businesses. It uses Google Gemini for content generation/TTS and Suno AI for music creation, with Puppeteer for server-side video rendering.
## Development Commands
```bash
# Start both frontend and backend concurrently (recommended)
./start.sh
# Or run manually:
npm run dev # Run frontend + backend together
npm run build # Build frontend for production
cd server && node index.js # Run backend only
# Install dependencies
npm install # Frontend dependencies
cd server && npm install # Backend dependencies
npm run build:all # Install all + build frontend
```
## Architecture
### Three-Layer Structure
- **Frontend**: React 19 + TypeScript + Vite (port 3000/5173)
- **Backend**: Express.js + SQLite (port 3001)
- **External APIs**: Google Gemini, Suno AI, YouTube Data API
### Key Directories
- `components/` - React UI components (InputForm, Navbar, ResultPlayer, etc.)
- `src/pages/` - Page components (GeneratorPage, Dashboard, AdminDashboard, Login/Register)
- `src/contexts/` - Global state (AuthContext for JWT auth, LanguageContext for i18n)
- `src/locales.ts` - Multi-language translations (ko, en, ja, zh, th, vi)
- `services/` - Frontend API services (geminiService, sunoService, ffmpegService, naverService)
- `server/` - Express backend
- `index.js` - Main server with auth, rendering, and API routes
- `db.js` - SQLite schema (users, history tables)
- `geminiBackendService.js` - Server-side Gemini operations
- `youtubeService.js` - YouTube upload via OAuth
- `downloads/` - Generated video storage
- `temp/` - Temporary rendering files
### Data Flow
1. User inputs business info/photos → GeneratorPage.tsx
2. Frontend calls Gemini API for creative content → geminiService.ts
3. Music generation via Suno proxy → sunoService.ts
4. Render request sent to backend → server/index.js
5. Puppeteer captures video, FFmpeg merges audio → final MP4 in downloads/
### Authentication
- JWT tokens stored in localStorage
- Auth middleware in server/index.js (`authenticateToken`, `requireAdmin`)
- Roles: 'user' and 'admin'
- Default admin: `admin` / `admin123`
### Environment Variables (.env in root)
```
VITE_GEMINI_API_KEY= # Required: Google AI Studio API key
SUNO_API_KEY= # Required: Suno AI proxy key
JWT_SECRET= # Required: JWT signing secret
FRONTEND_URL= # Optional: For CORS (default: http://localhost:5173)
PORT= # Optional: Backend port (default: 3001)
```
## Tech Notes
- Vite proxies `/api`, `/render`, `/downloads`, `/temp` to backend (port 3001)
- Path alias `@/` maps to project root
- SQLite database at `server/database.sqlite` (not tracked in git)
- Video rendering uses Puppeteer headless Chrome + FFmpeg
- Multi-language support: UI language separate from content generation language

279
castad-data/DEPLOY.md Normal file
View File

@ -0,0 +1,279 @@
# CaStAD 서버 배포 가이드
## 도메인 정보
- **Primary**: https://castad.ktenterprise.net
- **Secondary**: https://ado2.whitedonkey.kr
---
## 1. 서버 요구사항
| 항목 | 최소 | 권장 |
|------|------|------|
| **CPU** | 2 Core | 4 Core |
| **RAM** | 4GB | 8GB |
| **Storage** | 50GB | 100GB SSD |
| **OS** | Ubuntu 20.04+ | Ubuntu 22.04 LTS |
### 필수 소프트웨어
```bash
# Node.js 18+
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# PM2 (프로세스 관리)
sudo npm install -g pm2
# Nginx
sudo apt-get install -y nginx
# FFmpeg (영상 렌더링)
sudo apt-get install -y ffmpeg
# Python 3 (Instagram 서비스)
sudo apt-get install -y python3 python3-pip
# Chromium (Puppeteer용)
sudo apt-get install -y chromium-browser
# Certbot (SSL 인증서)
sudo apt-get install -y certbot python3-certbot-nginx
```
---
## 2. 프로젝트 클론
```bash
# 디렉토리 생성
sudo mkdir -p /var/www/castad
sudo chown $USER:$USER /var/www/castad
# Git 클론
cd /var/www/castad
git clone https://github.com/waabaa/19-claude-saas-castad.git .
# 실행 권한 부여
chmod +x startserver.sh
```
---
## 3. 환경 변수 설정
```bash
# .env 파일 생성
cp .env.production.example .env
# 편집
nano .env
```
**필수 변경 항목:**
```bash
# JWT 시크릿 (반드시 변경!)
JWT_SECRET=your-unique-secret-key-here
# Gemini API 키
VITE_GEMINI_API_KEY=your-key
# Suno API 키
SUNO_API_KEY=your-key
# Instagram 암호화 키 (생성 명령)
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
---
## 4. YouTube OAuth 설정
```bash
# Google Cloud Console에서 다운로드한 파일을 복사
cp /path/to/client_secret.json server/client_secret.json
```
---
## 5. Nginx 설정
```bash
# Nginx 설정 파일 복사
sudo cp nginx/castad.conf /etc/nginx/sites-available/castad.conf
# 심볼릭 링크 생성
sudo ln -s /etc/nginx/sites-available/castad.conf /etc/nginx/sites-enabled/
# 기본 사이트 비활성화 (선택)
sudo rm /etc/nginx/sites-enabled/default
# 문법 검사
sudo nginx -t
# Nginx 재시작
sudo systemctl restart nginx
```
---
## 6. SSL 인증서 발급
```bash
# Let's Encrypt SSL 인증서 발급
sudo certbot --nginx -d castad.ktenterprise.net -d ado2.whitedonkey.kr
# 자동 갱신 테스트
sudo certbot renew --dry-run
```
**Nginx 설정 업데이트 (자동 수정됨):**
인증서 경로가 다를 경우 `nginx/castad.conf` 수정:
```nginx
ssl_certificate /etc/letsencrypt/live/castad.ktenterprise.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/castad.ktenterprise.net/privkey.pem;
```
---
## 7. 서버 시작
```bash
# 서버 시작 (빌드 포함)
./startserver.sh start
# 상태 확인
./startserver.sh status
# 로그 보기
./startserver.sh logs
```
---
## 8. PM2 시작 시 자동 실행 설정
```bash
# 현재 상태 저장
pm2 save
# 시스템 시작 시 자동 실행
pm2 startup
# 표시되는 명령어 실행 (예시)
sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u ubuntu --hp /home/ubuntu
```
---
## 9. 방화벽 설정 (선택)
```bash
# UFW 설정
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 22/tcp
sudo ufw enable
```
---
## 명령어 요약
| 명령어 | 설명 |
|--------|------|
| `./startserver.sh start` | 서버 시작 (빌드 포함) |
| `./startserver.sh stop` | 서버 중지 |
| `./startserver.sh restart` | 서버 재시작 |
| `./startserver.sh status` | 상태 확인 |
| `./startserver.sh logs` | 로그 보기 |
| `./startserver.sh update` | Git pull + 재빌드 + 재시작 |
---
## 업데이트 방법
```bash
cd /var/www/castad
# 간단한 방법
./startserver.sh update
# 또는 수동으로
git pull origin main
npm install --legacy-peer-deps
cd server && npm install --legacy-peer-deps && cd ..
npm run build
pm2 restart all
```
---
## 트러블슈팅
### 502 Bad Gateway
```bash
# 백엔드 상태 확인
pm2 status
pm2 logs castad-backend
# 포트 확인
sudo netstat -tlnp | grep 3001
```
### SSL 인증서 오류
```bash
# 인증서 갱신
sudo certbot renew
# Nginx 재시작
sudo systemctl restart nginx
```
### Puppeteer 오류
```bash
# Chromium 설치 확인
chromium-browser --version
# 또는 환경변수 설정
export PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
```
### Instagram 서비스 오류
```bash
# Python 의존성 재설치
pip3 install -r server/instagram/requirements.txt
# 서비스 재시작
pm2 restart castad-instagram
```
---
## 백업
```bash
# 데이터베이스 백업
cp server/database.sqlite backups/database_$(date +%Y%m%d).sqlite
# 업로드 파일 백업
tar -czvf backups/uploads_$(date +%Y%m%d).tar.gz server/downloads/
```
---
## 모니터링
```bash
# PM2 모니터링
pm2 monit
# 시스템 리소스
htop
# Nginx 접속 로그
tail -f /var/log/nginx/castad_access.log
# Nginx 에러 로그
tail -f /var/log/nginx/castad_error.log
```

85
castad-data/GEMINI.md Normal file
View File

@ -0,0 +1,85 @@
# BizVibe - AI Music Video Generator
## Project Overview
**BizVibe** is a React-based web application designed to automatically generate marketing music videos for businesses. It leverages the power of Google's Gemini ecosystem and Suno AI to create a comprehensive multimedia package including:
* **Creative Text:** Ad copy and lyrics/scripts generated by **Gemini 3 Pro**.
* **Audio:**
* Custom songs composed by **Suno AI** (V5 model).
* Professional voiceovers generated by **Gemini TTS** (`gemini-2.5-flash-preview-tts`).
* **Visuals:**
* High-quality ad posters designed by **Gemini 3 Pro Image** (`gemini-3-pro-image-preview`).
* Cinematic video backgrounds created by **Veo** (`veo-3.1-fast-generate-preview`).
The application provides an end-to-end flow: collecting business details -> generating assets -> previewing the final music video.
## Architecture & Tech Stack
* **Frontend Framework:** React 19 with Vite.
* **Language:** TypeScript.
* **AI Integration:**
* `@google/genai` SDK for interacting with Gemini and Veo models.
* Custom REST API integration for Suno AI (via CORS proxy).
* **Media Processing:** FFmpeg (WASM) is included in dependencies (`@ffmpeg/ffmpeg`), likely for client-side media assembly.
* **Styling:** Tailwind CSS.
* **State Management:** React Context / Local State.
## Building and Running
### Prerequisites
* Node.js (v18+ recommended)
* A valid Google Cloud Project with Gemini API access enabled.
### Installation
Install the project dependencies:
```bash
npm install
```
### Development Server
Start the local development server:
```bash
npm run dev
```
Access the app at `http://localhost:5173` (default Vite port).
### Production Build
Create a production-ready build:
```bash
npm run build
```
Preview the build locally:
```bash
npm run preview
```
### Configuration
* **API Key:** The application requires a Google Gemini API Key.
* It checks for `process.env.API_KEY`.
* It also includes an `ApiKeySelector` component that allows users to select/input a key if running in a specific AI Studio environment.
* **Suno API:** The `sunoService.ts` currently contains a hardcoded API key and uses a CORS proxy. *Note: This should be externalized in a production environment.*
## Development Conventions
### File Structure
* `src/components/`: Reusable UI components (Input forms, Result players, Loading overlays).
* `src/services/`: specialized modules for API interactions.
* `geminiService.ts`: Handles Text, Image, Video (Veo), and TTS generation.
* `sunoService.ts`: Handles music generation via Suno API.
* `ffmpegService.ts`: Media processing utilities.
* `src/types.ts`: Centralized TypeScript definitions for domain models (`BusinessInfo`, `GeneratedAssets`, etc.).
* `App.tsx`: Main application controller handling the generation workflow state machine.
### Code Style
* **Components:** Functional components with Hooks.
* **Styling:** Utility-first CSS using Tailwind classes.
* **Async Handling:** `async/await` pattern used extensively in services with error handling for API failures.
* **Type Safety:** Strict TypeScript interfaces for all data models and API responses.
### Key Workflows
1. **Input:** User submits `BusinessInfo` (including images).
2. **Processing (Sequential/Parallel):**
* Text content is generated first.
* Audio (Song or TTS) is generated using the text.
* Poster image is generated using uploaded images.
* Video background is generated using the Poster image.
3. **Output:** `GeneratedAssets` are aggregated and displayed in the `ResultPlayer`.

305
castad-data/PLAN.md Normal file
View File

@ -0,0 +1,305 @@
# CastAD SaaS UI/UX 전체 리디자인 계획
## 개요
POC 상태의 펜션 쇼츠 자동 생성 웹사이트를 프로페셔널한 SaaS 서비스로 전환
## 기술 스택 결정
| 항목 | 현재 | 변경 후 |
|------|------|---------|
| CSS Framework | Tailwind CDN | Tailwind npm + PostCSS |
| UI Components | 직접 구현 | shadcn/ui (Radix 기반) |
| Icons | lucide-react | lucide-react (유지) |
| Design System | 없음 | CSS Variables 기반 토큰 |
| Animations | index.html 인라인 | tailwind-animate + CSS 파일 분리 |
## 디자인 방향
### 컬러 팔레트 (다크 테마)
- **Background**: `#09090b` (zinc-950)
- **Card**: `#18181b` (zinc-900)
- **Border**: `#27272a` (zinc-800)
- **Primary**: `#a855f7` (purple-500) → `#9333ea` (purple-600)
- **Accent**: `#06b6d4` (cyan-500)
- **Success**: `#22c55e` (green-500)
- **Destructive**: `#ef4444` (red-500)
### 타이포그래피
- **Display**: Inter (600-700)
- **Body**: Inter (400)
- **Mono**: JetBrains Mono
### 레이아웃 원칙
- 최대 너비: 1280px (7xl)
- 섹션 패딩: 24-32px
- 카드 둥글기: 12px (rounded-xl)
- 일관된 8px 그리드 시스템
---
## 단계별 구현 계획
### Phase 1: 인프라 설정 (기반 작업)
#### 1.1 Tailwind CSS npm 전환
- [ ] `tailwindcss`, `postcss`, `autoprefixer` 설치
- [ ] `tailwind.config.js` 생성 (커스텀 컬러, 폰트, 애니메이션)
- [ ] `postcss.config.js` 생성
- [ ] `src/styles/globals.css` 생성 (Tailwind directives)
- [ ] index.html에서 CDN 제거
#### 1.2 shadcn/ui 초기화
- [ ] `npx shadcn@latest init` 실행
- [ ] components.json 설정 (경로, 스타일)
- [ ] `src/components/ui/` 디렉토리 구조 설정
#### 1.3 디자인 토큰 설정
- [ ] CSS variables 정의 (colors, radius, spacing)
- [ ] 다크 테마 기본값 설정
- [ ] 폰트 로딩 최적화 (Google Fonts → next/font 스타일)
---
### Phase 2: 기본 컴포넌트 구축
#### 2.1 shadcn/ui 컴포넌트 설치
```bash
npx shadcn@latest add button card input label select textarea
npx shadcn@latest add dialog sheet tabs toast sonner
npx shadcn@latest add dropdown-menu avatar badge separator
npx shadcn@latest add progress skeleton scroll-area
npx shadcn@latest add form (react-hook-form + zod)
```
#### 2.2 커스텀 컴포넌트 생성
- [ ] `PageHeader` - 페이지 제목 + 설명
- [ ] `PageContainer` - 일관된 레이아웃 래퍼
- [ ] `FeatureCard` - 기능 소개 카드
- [ ] `PricingCard` - 가격표 카드
- [ ] `StatCard` - 통계 카드
- [ ] `VideoPlayer` - 영상 재생기 (기존 ResultPlayer 리팩토링)
- [ ] `LoadingSpinner` - 로딩 인디케이터
- [ ] `EmptyState` - 빈 상태 표시
#### 2.3 레이아웃 컴포넌트
- [ ] `MainLayout` - 전체 앱 레이아웃
- [ ] `DashboardLayout` - 대시보드용 사이드바 레이아웃
- [ ] `AuthLayout` - 로그인/회원가입 전용 레이아웃
---
### Phase 3: Navbar 리디자인
#### 현재 문제점
- 모바일 메뉴 없음
- 언어 선택 UI 투박함
- 사용자 메뉴 단순함
#### 개선 사항
- [ ] 반응형 모바일 메뉴 (Sheet 컴포넌트 활용)
- [ ] 언어 선택 드롭다운 개선
- [ ] 사용자 아바타 + 드롭다운 메뉴
- [ ] 네비게이션 링크 호버 효과
- [ ] 스크롤 시 배경 blur 효과
---
### Phase 4: Landing Page 리디자인
#### 4.1 Hero 섹션
- [ ] 더 절제된 타이포그래피 (text-4xl → text-5xl)
- [ ] 애니메이션 최적화 (framer-motion 고려)
- [ ] CTA 버튼 개선 (그래디언트 보더)
- [ ] 신뢰도 지표 리디자인
#### 4.2 Features 섹션
- [ ] FeatureCard 컴포넌트로 통일
- [ ] 아이콘 + 제목 + 설명 구조
- [ ] 호버 시 미묘한 상승 효과
#### 4.3 How It Works
- [ ] 스텝 카드 (번호 + 아이콘 + 설명)
- [ ] 연결선 또는 화살표 그래픽
#### 4.4 Success Cases
- [ ] 캐러셀 개선 (자동 재생 + 수동 조작)
- [ ] 후기 카드 디자인 개선
#### 4.5 Pricing 섹션
- [ ] PricingCard 컴포넌트 활용
- [ ] 인기 플랜 하이라이트
- [ ] 기능 비교표 추가 고려
#### 4.6 Footer
- [ ] 링크 구조화
- [ ] 소셜 미디어 아이콘
- [ ] 저작권 정보
---
### Phase 5: Generator Page 리디자인
#### 5.1 InputForm 분할 (47KB → 6개 컴포넌트)
- [ ] `BusinessInfoSection` - 비즈니스 기본 정보
- [ ] `ImageUploadSection` - 이미지 업로드
- [ ] `AudioSettingsSection` - 오디오 설정
- [ ] `VisualSettingsSection` - 비주얼 설정
- [ ] `CategorySection` - 카테고리 선택
- [ ] `FormActions` - 제출 버튼
#### 5.2 폼 UI 개선
- [ ] shadcn Form + react-hook-form + zod 적용
- [ ] 실시간 유효성 검사
- [ ] 섹션별 접기/펼치기 (Collapsible)
- [ ] 진행 상태 인디케이터
#### 5.3 Loading 상태 개선
- [ ] 스켈레톤 UI
- [ ] 단계별 진행 표시 (Stepper)
- [ ] 취소 기능
---
### Phase 6: Result Player 리디자인
#### 6.1 ResultPlayer 분할 (38KB → 5개 컴포넌트)
- [ ] `VideoPreview` - 영상 미리보기
- [ ] `AudioControls` - 오디오 컨트롤
- [ ] `TextOverlayPreview` - 텍스트 오버레이
- [ ] `DownloadOptions` - 다운로드 옵션
- [ ] `SharePanel` - 공유 기능
#### 6.2 UI 개선
- [ ] 탭 UI 개선 (Tabs 컴포넌트)
- [ ] 진행률 표시 개선
- [ ] 토스트 알림 (Sonner)
---
### Phase 7: Dashboard 리디자인
#### 7.1 사용자 대시보드
- [ ] 사이드바 레이아웃 적용
- [ ] 통계 카드 (생성 횟수, 저장 공간 등)
- [ ] 최근 생성물 그리드
- [ ] 필터링 + 검색 기능
- [ ] 페이지네이션
#### 7.2 관리자 대시보드
- [ ] DataTable 컴포넌트 (정렬, 필터, 페이지네이션)
- [ ] 사용자 관리 UI 개선
- [ ] 통계 차트 (선택적)
---
### Phase 8: Auth Pages 리디자인
#### 8.1 Login/Register
- [ ] AuthLayout 적용
- [ ] 폼 유효성 검사 개선
- [ ] 소셜 로그인 버튼 (UI만, 추후 구현)
- [ ] 비밀번호 강도 표시
---
### Phase 9: 반응형 및 접근성
#### 9.1 반응형
- [ ] 모든 페이지 모바일 테스트
- [ ] 터치 타겟 크기 확인 (44x44px 최소)
- [ ] 가로 스크롤 제거
#### 9.2 접근성
- [ ] ARIA 라벨 추가
- [ ] 키보드 네비게이션 확인
- [ ] 색상 대비 검사 (WCAG AA)
- [ ] 포커스 상태 시각화
---
### Phase 10: 마무리
#### 10.1 성능 최적화
- [ ] 이미지 최적화 (lazy loading)
- [ ] 번들 사이즈 분석
- [ ] 불필요한 의존성 제거
#### 10.2 코드 정리
- [ ] 사용하지 않는 코드 제거
- [ ] TypeScript 타입 정리
- [ ] 컴포넌트 문서화 (주석)
---
## 파일 구조 변경
```
/
├── src/
│ ├── components/
│ │ ├── ui/ # shadcn/ui 컴포넌트
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── input.tsx
│ │ │ └── ...
│ │ ├── layout/ # 레이아웃 컴포넌트
│ │ │ ├── MainLayout.tsx
│ │ │ ├── DashboardLayout.tsx
│ │ │ └── AuthLayout.tsx
│ │ ├── generator/ # Generator 관련 컴포넌트
│ │ │ ├── BusinessInfoSection.tsx
│ │ │ ├── ImageUploadSection.tsx
│ │ │ └── ...
│ │ ├── player/ # Player 관련 컴포넌트
│ │ │ ├── VideoPreview.tsx
│ │ │ └── ...
│ │ └── shared/ # 공유 컴포넌트
│ │ ├── PageHeader.tsx
│ │ ├── FeatureCard.tsx
│ │ └── ...
│ ├── styles/
│ │ ├── globals.css # Tailwind + CSS variables
│ │ └── animations.css # 텍스트 이펙트 (유지)
│ ├── lib/
│ │ └── utils.ts # cn() 등 유틸리티
│ └── pages/ # 페이지 컴포넌트
├── components/ # 기존 컴포넌트 (점진적 이전)
├── tailwind.config.js # 새로 생성
├── postcss.config.js # 새로 생성
└── components.json # shadcn/ui 설정
```
---
## 예상 작업량
| Phase | 작업 내용 | 파일 수 |
|-------|----------|--------|
| 1 | 인프라 설정 | 5-6 |
| 2 | 기본 컴포넌트 | 15-20 |
| 3 | Navbar | 2-3 |
| 4 | Landing Page | 5-6 |
| 5 | Generator Page | 8-10 |
| 6 | Result Player | 6-8 |
| 7 | Dashboard | 4-5 |
| 8 | Auth Pages | 2-3 |
| 9 | 반응형/접근성 | 전체 수정 |
| 10 | 마무리 | 전체 검토 |
---
## 주의사항
1. **점진적 마이그레이션**: 기존 기능이 깨지지 않도록 단계별 진행
2. **텍스트 이펙트 보존**: 11가지 텍스트 이펙트는 유지 (핵심 기능)
3. **다국어 지원 유지**: i18n 구조 그대로 유지
4. **백엔드 연동 유지**: API 호출 로직 변경 없음
---
## 승인 필요 항목
진행 전 확인:
1. 이 계획대로 진행해도 될까요?
2. 특별히 우선순위를 높이거나 제외할 부분이 있나요?
3. Phase 1부터 순차적으로 진행할까요?

4014
castad-data/README.md Normal file

File diff suppressed because it is too large Load Diff

360
castad-data/SALES_MANUAL.md Normal file
View File

@ -0,0 +1,360 @@
# CaStAD (카스타드) 영업 매뉴얼 v3.0
## 솔루션 한 줄 소개
> **"네이버 플레이스 URL 하나로 15초만에 인스타그램 릴스, 틱톡, 유튜브 쇼츠 영상을 자동 생성하는 AI 마케팅 플랫폼"**
---
## 목차
1. [솔루션 개요](#1-솔루션-개요)
2. [타겟 고객](#2-타겟-고객)
3. [핵심 가치 제안](#3-핵심-가치-제안)
4. [경쟁 우위](#4-경쟁-우위)
5. [요금제 안내](#5-요금제-안내)
6. [데모 시나리오](#6-데모-시나리오)
7. [예상 질문 및 답변 (FAQ)](#7-예상-질문-및-답변-faq)
8. [성공 사례](#8-성공-사례)
9. [계약 절차](#9-계약-절차)
10. [기술 지원 정책](#10-기술-지원-정책)
---
## 1. 솔루션 개요
### CaStAD란?
CaStAD(카스타드)는 펜션, 풀빌라, 리조트 등 숙박업 사업자를 위한 **AI 기반 마케팅 영상 자동 생성 SaaS 플랫폼**입니다.
### 핵심 기능
| 기능 | 설명 |
|------|------|
| **AI 영상 자동 생성** | 네이버 플레이스 URL만 입력하면 사진, 리뷰 등을 분석하여 자동으로 홍보 영상 생성 |
| **AI 음악 생성** | 펜션 분위기에 맞는 배경음악 자동 생성 (Suno AI 연동) |
| **AI 카피라이팅** | Google Gemini AI가 감성적인 광고 문구 자동 생성 |
| **다국어 지원** | 한국어, 영어, 일본어, 중국어, 태국어, 베트남어 6개국어 |
| **3채널 동시 업로드** | YouTube, Instagram Reels, TikTok 원클릭 업로드 |
| **통합 분석 대시보드** | 모든 채널의 조회수, 참여율 등을 한눈에 확인 |
### 주요 특징
- **시간 절약**: 기존 2시간 → 15초로 영상 제작 시간 99% 단축
- **비용 절감**: 영상 제작 외주비 월 50만원+ → 월 2.9만원부터
- **전문가 수준**: AI가 최신 트렌드를 반영한 전문적인 영상 제작
- **자동화**: 정기적인 업로드까지 완전 자동화 가능
---
## 2. 타겟 고객
### 주요 타겟
| 업종 | 예시 | 니즈 |
|------|------|------|
| **펜션** | 전국 2만+ 펜션 | 저렴하고 쉬운 마케팅 |
| **풀빌라** | 프리미엄 풀빌라 | 고급스러운 영상 콘텐츠 |
| **리조트/호텔** | 중소형 리조트 | 일관된 브랜딩 영상 |
| **글램핑** | 글램핑장 | 감성적인 분위기 전달 |
| **게스트하우스** | 외국인 대상 숙소 | 다국어 마케팅 |
### 이상적인 고객 프로필
- 네이버 플레이스에 등록된 사업자
- SNS 마케팅 필요성을 인지하지만 시간/역량 부족
- 마케팅 예산이 제한적인 개인 사업자
- 여러 펜션을 운영하는 법인 사업자
### 고객 Pain Point
1. "영상 만들 시간이 없어요"
2. "영상 편집할 줄 몰라요"
3. "외주 맡기기엔 너무 비싸요"
4. "SNS에 올리는 것도 귀찮아요"
5. "어떤 콘텐츠가 효과적인지 모르겠어요"
---
## 3. 핵심 가치 제안
### Value Proposition Canvas
**고객 문제 (Pains)**
- 영상 제작 시간 부족
- 편집 기술 부족
- 높은 외주 비용
- 일관성 없는 마케팅
**솔루션 제공 (Gains)**
- 15초 만에 영상 완성
- 기술 필요 없음
- 월 2.9만원부터
- AI가 일관된 품질 보장
### ROI 계산 예시
**현재 비용 (외주 의뢰 시)**
- 영상 제작: 30만원/건
- 월 2개 제작: 60만원
- 연간: 720만원
**CaStAD 사용 시 (Pro 플랜)**
- 월 요금: 9.9만원
- 연간: 118.8만원
- **절감 효과: 연 600만원 이상**
---
## 4. 경쟁 우위
### 경쟁사 비교표
| 항목 | CaStAD | A사 (영상 편집 앱) | B사 (마케팅 대행) |
|------|--------|------------------|------------------|
| 자동화 수준 | ★★★★★ | ★★☆☆☆ | ★☆☆☆☆ |
| 가격 | ★★★★★ | ★★★☆☆ | ★☆☆☆☆ |
| 숙박업 특화 | ★★★★★ | ★☆☆☆☆ | ★★★☆☆ |
| AI 음악 생성 | ★★★★★ | ☆☆☆☆☆ | ☆☆☆☆☆ |
| 3채널 통합 | ★★★★★ | ☆☆☆☆☆ | ★★★☆☆ |
| 다국어 지원 | ★★★★★ | ★☆☆☆☆ | ★★☆☆☆ |
### 우리만의 차별점
1. **숙박업 완전 특화**
- 네이버 플레이스 데이터 자동 추출
- 숙박업에 최적화된 AI 프롬프트
- 업종별 최적화된 템플릿
2. **완전 자동화**
- URL 입력 → 영상 생성 → SNS 업로드까지 원스톱
- 예약 업로드 기능으로 정기 마케팅 자동화
3. **AI 음악 생성**
- 로열티 프리 음악 걱정 없음
- 펜션 분위기에 맞춤 생성
4. **합리적인 가격**
- 영상 1개 비용 = 커피 한잔 가격
---
## 5. 요금제 안내
### 요금제 비교
| 항목 | Free | Basic | Pro | Business |
|------|------|-------|-----|----------|
| 월 요금 | 무료 | ₩29,000 | ₩99,000 | ₩299,000 |
| 월 크레딧 | 10회 | 15회 | 75회 | 무제한 |
| 관리 펜션 | 1개 | 1개 | 5개 | 무제한 |
| YouTube 업로드 | ✓ | ✓ | ✓ | ✓ |
| Instagram 업로드 | - | ✓ | ✓ | ✓ |
| TikTok 업로드 | - | ✓ | ✓ | ✓ |
| 다국어 콘텐츠 | - | - | ✓ | ✓ |
| 프리미엄 템플릿 | - | - | ✓ | ✓ |
| 전담 매니저 | - | - | - | ✓ |
| 분석 리포트 | - | - | 주간 | 일간 |
### 타겟별 추천 플랜
| 고객 유형 | 추천 플랜 | 이유 |
|----------|----------|------|
| 개인 펜션 운영자 | Basic | 1개 펜션 관리에 충분한 기능 |
| 2-5개 펜션 운영 법인 | Pro | 다중 펜션 관리 + 다국어 |
| 리조트/체인 | Business | 무제한 + 전담 지원 |
| 시작 테스트 | Free | 무료로 기능 체험 |
### 업셀링 포인트
**Free → Basic**
- "월 10회로 부족하시죠? 15회로 늘리면서 Instagram까지!"
**Basic → Pro**
- "펜션 추가하셨나요? Pro면 5개까지 관리 가능!"
- "외국인 손님 많으시죠? 다국어로 글로벌 마케팅!"
**Pro → Business**
- "월 75회 이상 필요하시면 무제한이 경제적!"
- "전담 매니저가 성과 분석까지 도와드립니다!"
---
## 6. 데모 시나리오
### 5분 데모 시나리오
**1단계: 문제 제기 (30초)**
> "사장님, 요즘 인스타 릴스나 틱톡 마케팅 하고 계신가요? 하시려면 영상을 만들어야 하는데 시간도 없고 어렵죠?"
**2단계: 솔루션 소개 (30초)**
> "저희 CaStAD는 네이버 플레이스 URL만 넣으면 자동으로 영상이 만들어집니다. 한번 보시겠어요?"
**3단계: 실시간 데모 (2분)**
1. 고객 펜션의 네이버 플레이스 URL 복사
2. CaStAD에 붙여넣기
3. 15초 후 영상 생성 완료
4. 영상 미리보기 및 다운로드
**4단계: 기능 설명 (1분)**
> "이 영상이 자동으로 만들어졌습니다. 음악도 AI가 만든 거예요.
> 여기서 바로 유튜브, 인스타, 틱톡에 업로드할 수 있어요."
**5단계: 클로징 (30초)**
> "지금 무료 체험 가입하시면 10회까지 무료로 사용 가능합니다.
> 한번 사용해보시고 효과 있으시면 Basic 플랜 추천드릴게요."
### 핵심 데모 포인트
1. **고객의 실제 펜션으로 데모** → 즉각적인 공감
2. **15초 생성 시간 강조** → "이게 15초 만에요?"
3. **음악도 AI 생성** → "음악도 따로 안 구해도 돼요?"
4. **원클릭 업로드** → "바로 올릴 수 있네요!"
---
## 7. 예상 질문 및 답변 (FAQ)
### 제품 관련
**Q: 영상 품질이 어느 정도예요?**
> A: 1080p Full HD로 제작되며, 인스타 릴스나 틱톡에 올리기에 전문가 수준입니다. 실제 데모 영상을 보여드릴까요?
**Q: 네이버 플레이스가 없으면 못 써요?**
> A: 직접 사진 업로드도 가능합니다. 다만 네이버 플레이스가 있으면 리뷰, 정보가 자동 추출되어 더 풍부한 영상이 만들어져요.
**Q: 음악 저작권 문제는 없나요?**
> A: 모든 음악이 Suno AI로 자동 생성되어 저작권 문제가 전혀 없습니다. 상업적 사용도 100% 가능해요.
**Q: 영상 수정할 수 있어요?**
> A: 마음에 안 드시면 다른 스타일로 다시 생성하실 수 있어요. 생성 횟수 내에서 무제한 재생성 가능합니다.
### 가격 관련
**Q: 왜 월 구독제예요?**
> A: AI 서버 운영 비용이 들기 때문에 구독제로 운영됩니다. 대신 영상 1개당 비용으로 계산하면 건당 2,000원도 안 돼요.
**Q: 계약 기간은요?**
> A: 월 단위 구독이라 언제든 해지 가능합니다. 연 결제 시 20% 할인 혜택도 있어요.
**Q: 크레딧이 남으면 이월되나요?**
> A: 죄송하지만 미사용 크레딧은 이월되지 않습니다. 대신 크레딧 추가 구매가 가능해요.
### 기술 관련
**Q: 설치해야 해요?**
> A: 웹 기반이라 설치 필요 없습니다. 크롬 브라우저만 있으면 PC, 모바일 어디서든 사용 가능해요.
**Q: 인스타 연동이 안전한가요?**
> A: 비밀번호는 암호화되어 저장되고, 원하시면 언제든 연결 해제 가능합니다. 인스타 공식 연동 방식도 지원해요.
**Q: 여러 명이 함께 쓸 수 있어요?**
> A: Business 플랜에서 팀 계정 기능을 지원합니다. 직원분들과 함께 사용 가능해요.
---
## 8. 성공 사례
### 사례 1: 제주 오션뷰 펜션
> "인스타 팔로워가 한 달 만에 3배 늘었어요.
> 매주 릴스 올리는데 10분도 안 걸려요."
>
> - 월 영상 제작: 15개
> - 예약률 증가: 25%
> - 팔로워 증가: 850명 → 2,400명
### 사례 2: 강원도 풀빌라 체인 (5개 지점)
> "각 지점 개별로 마케팅하는 게 힘들었는데,
> 이제 한 곳에서 5개 다 관리해요."
>
> - 관리 시간: 주 10시간 → 1시간
> - 월 콘텐츠: 5개 → 50개
> - 마케팅 비용: 200만원 → 10만원
### 사례 3: 경기도 글램핑장
> "틱톡에서 영상 하나가 10만뷰 나왔어요.
> 외국인 예약이 갑자기 늘어났어요."
>
> - 틱톡 조회수: 평균 1,000 → 15,000
> - 외국인 예약: 10% → 35%
> - 네이버 플레이스 순위: 8위 → 2위
---
## 9. 계약 절차
### 신규 계약 프로세스
```
1. 무료 체험
2. 데모 및 상담
3. 플랜 선택
4. 결제 및 계정 생성
5. 초기 설정 지원
6. 정기 영상 생성 시작
```
### 필요 서류
- 사업자등록증 (세금계산서 발행 시)
- 결제 카드 정보 (자동결제)
### 결제 방법
- 신용카드 (자동결제)
- 계좌이체 (연 결제 시)
- 세금계산서 발행 가능
---
## 10. 기술 지원 정책
### 지원 채널
| 플랜 | 이메일 | 채팅 | 전화 | 전담 매니저 |
|------|--------|------|------|------------|
| Free | ✓ (48h) | - | - | - |
| Basic | ✓ (24h) | ✓ | - | - |
| Pro | ✓ (12h) | ✓ | ✓ | - |
| Business | ✓ (4h) | ✓ | ✓ | ✓ |
### 지원 범위
- 계정 및 결제 문의
- 기능 사용 방법
- SNS 연동 문제
- 영상 생성 오류
- 비즈니스 플랜: 마케팅 전략 컨설팅 포함
### 응답 시간 보장
- Business 플랜: 4시간 내 응답 보장
- 긴급 이슈: 1시간 내 처리
---
## 영업 자료 체크리스트
### 필수 준비물
- [ ] 데모 계정 (관리자 권한)
- [ ] 노트북 + 인터넷 (데모용)
- [ ] 명함 및 회사 소개서
- [ ] 요금표 출력물
- [ ] 계약서 양식
### 현장 체크
- [ ] 고객 네이버 플레이스 URL 확인
- [ ] 현재 마케팅 방법 파악
- [ ] 예산 및 결정권자 확인
- [ ] 경쟁사 사용 여부 확인
- [ ] 다음 미팅 일정 잡기
---
## 연락처
- **영업 문의**: sales@castad.io
- **기술 지원**: support@castad.io
- **파트너십**: partners@castad.io
---
*이 매뉴얼은 영업팀 내부용입니다. 외부 유출을 금합니다.*
**마지막 업데이트: 2025년 12월**
**버전: 3.0.0**

View File

@ -0,0 +1,21 @@
#!/bin/bash
# 배포 가이드 (서버에서 실행하세요)
# 1. 압축 해제
# tar -xzvf bizvibe_deploy.tar.gz
# 2. 필수 패키지 설치 (Ubuntu 기준)
# sudo apt-get update && sudo apt-get install -y ffmpeg fonts-noto-cjk
# 3. 의존성 설치 및 빌드
# ./deploy.sh
# 4. 환경 변수 설정
# .env 파일을 열어 API 키들을 확인하고 채워주세요.
# 특히 SUNO_API_KEY, VITE_GEMINI_API_KEY 확인 필수!
# 5. 서버 실행
# ./start.sh
echo "배포 준비 완료! bizvibe_deploy.tar.gz 파일을 서버로 전송하세요."

View File

@ -0,0 +1,80 @@
# ADo4 사용자 매뉴얼 (User Manual)
**ADo4**는 AI 기술을 활용하여 누구나 쉽게 전문가 수준의 마케팅 비디오를 만들 수 있는 도구입니다. 이 매뉴얼을 따라 단계별로 영상을 만들어보세요.
---
## 1. 시작하기 (메인 화면)
서비스에 접속하면 가장 먼저 보게 되는 메인 대시보드입니다. 왼쪽은 **데이터 입력 영역**, 오른쪽은 **설정 영역**으로 구성되어 있습니다.
### 1-1. 업체 정보 입력 (자동 검색 기능)
가장 쉬운 방법은 **[정보 자동 검색]** 기능을 활용하는 것입니다.
* **Google Maps 검색:** '지도 검색 (Global)' 입력창에 `지역명 + 상호명` (예: "강남 스타벅스", "군산 이성당")을 입력하고 **[검색]** 버튼을 누르세요.
* **Naver 플레이스:** 네이버 지도 URL이 있다면 붙여넣고 **[가져오기]**를 누르면 더욱 정확한 한국형 데이터를 가져올 수 있습니다.
> 💡 **Tip:** 자동 검색을 사용하면 업체명, 설명, 대표 사진(AI가 선별한 베스트 컷)이 자동으로 채워집니다.
---
## 2. 세부 설정 (Customization)
자동으로 입력된 정보를 확인하고, 원하는 스타일로 다듬어 보세요.
### 2-1. 프로젝트 설정
* **브랜드 이름 & 설명:** 자동 입력된 내용을 수정하거나 직접 입력합니다. 이곳에 적힌 내용(분위기, 특징)을 바탕으로 AI가 가사를 씁니다.
* **화면 비율:**
* `16:9`: 유튜브, TV, 모니터용 (가로형)
* `9:16`: 인스타그램 릴스, 틱톡, 유튜브 쇼츠용 (세로형)
* **비주얼 스타일:**
* `슬라이드`: 사진들이 부드럽게 전환되는 슬라이드쇼 영상 (추천)
* `AI 비디오`: 사진을 바탕으로 AI가 움직이는 영상을 생성 (생성 시간 김)
### 2-2. 오디오 및 자막 설정
* **오디오 모드:**
* `노래 (Song)`: 나만의 오리지널 CM송을 작곡합니다.
* `성우 (Narration)`: 차분하고 신뢰감 있는 성우 목소리로 읽어줍니다.
* **장르 선택:** 팝, 발라드, 재즈, 힙합 등 원하는 분위기를 고르세요.
* **자막 스타일:** 영상 위에 입혀질 자막의 디자인(네온, 타자기 등)을 선택하세요.
---
## 3. 영상 생성 시작
모든 설정이 끝났다면, 우측 하단의 **[영상 생성 시작]** 버튼을 클릭하세요.
> ⏳ **소요 시간:** 약 1~2분 정도 소요됩니다.
> AI가 시나리오 작성 -> 작곡 -> 이미지 검수 -> 영상 합성을 진행하는 동안 잠시만 기다려주세요.
---
## 4. 결과 확인 및 저장 (플레이어 화면)
영상이 완성되면 자동으로 **결과 플레이어** 화면이 나타납니다.
### 4-1. 영상 재생
* 가운데 **[재생 ▶]** 버튼을 눌러 완성된 영상을 감상해 보세요.
* 가사나 광고 문구가 음악에 맞춰 화면에 나타나는 것을 볼 수 있습니다.
### 4-2. 영상 저장 및 활용
* **[영상 저장 (텍스트 효과 포함)]:** 완성된 고화질 MP4 파일을 내 컴퓨터로 다운로드합니다. 동시에 서버에서 **YouTube 자동 업로드** 준비가 진행됩니다.
* **[YouTube 업로드]:** (영상 저장 후 활성화됨) 클릭 한 번으로 내 유튜브 채널에 영상을 업로드합니다. 업로드가 완료되면 **[보러가기]** 버튼이 나타납니다.
* **[빠른 저장 (효과X)]:** 자막 효과 없이 원본 영상과 음악만 빠르게 합쳐서 저장하고 싶을 때 사용합니다.
---
## ❓ 자주 묻는 질문 (FAQ)
**Q. 영상 생성이 실패했어요.**
A. 일시적인 네트워크 문제일 수 있습니다. 잠시 후 다시 시도해 주세요. 계속 실패한다면 입력한 이미지의 용량이 너무 크거나(개당 10MB 이상), 인터넷 연결이 불안정할 수 있습니다.
**Q. 유튜브 업로드가 안 돼요.**
A. 최초 1회 인증이 필요합니다. 관리자에게 문의하거나 서버 설정 가이드를 참고하여 구글 계정 인증을 완료해주세요.
**Q. 세로 영상(9:16)인데 사진이 잘려요.**
A. AI가 자동으로 최적의 구도를 찾지만, 가로로 긴 사진은 양옆이 잘릴 수밖에 없습니다. 되도록 세로로 찍은 사진을 업로드하거나, AI에게 맡겨주세요.
---
© 2025 ADo4. All Rights Reserved.

View File

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/src/components",
"utils": "@/src/lib/utils",
"ui": "@/src/components/ui",
"lib": "@/src/lib",
"hooks": "@/src/hooks"
}
}

View File

@ -0,0 +1,150 @@
import React, { useState, useEffect } from 'react';
import { Key } from 'lucide-react';
/**
* ApiKeySelector props
* @interface ApiKeySelectorProps
* @property {(key?: string) => void} onKeySelected - API . ( )
* @property {string | null} [initialError] - ( )
*/
interface ApiKeySelectorProps {
onKeySelected: (key?: string) => void;
initialError?: string | null;
}
/**
* API
* Gemini Veo Google Cloud Project API ,
* AI Studio .
*/
const ApiKeySelector: React.FC<ApiKeySelectorProps> = ({ onKeySelected, initialError }) => {
const [loading, setLoading] = useState(false); // 로딩 상태 관리
const [error, setError] = useState<string | null>(initialError || null); // 에러 메시지 관리
const [manualKey, setManualKey] = useState(''); // 수동으로 입력된 API 키
const [isAiStudio, setIsAiStudio] = useState(false); // 현재 환경이 Google AI Studio인지 여부
/**
* API
* Google AI Studio , API .
*/
const checkKey = async () => {
try {
// window 객체에 aistudio 속성이 있는지 확인하여 AI Studio 환경을 감지합니다.
if ((window as any).aistudio) {
setIsAiStudio(true);
// AI Studio에서 이미 API 키가 선택되어 있는지 확인합니다.
const hasKey = await (window as any).aistudio.hasSelectedApiKey();
if (hasKey && !initialError) {
// 키가 이미 선택되어 있고 초기 에러가 없으면 onKeySelected 콜백을 호출하여 앱 시작
onKeySelected();
}
}
} catch (e) {
console.error("API 키 확인 중 오류 발생:", e);
// 오류가 발생해도 isAiStudio 상태는 유지
}
};
// 컴포넌트가 처음 렌더링될 때 한 번만 checkKey 함수 실행
useEffect(() => {
checkKey();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* AI Studio API
* AI Studio `openSelectKey()` API UI .
*/
const handleSelectKey = async () => {
setLoading(true); // 로딩 상태 시작
setError(null); // 기존 에러 메시지 초기화
try {
if((window as any).aistudio) {
await (window as any).aistudio.openSelectKey(); // AI Studio 키 선택 대화 상자 열기
onKeySelected(); // 키 선택 성공 시 콜백 호출
}
} catch (e: any) {
console.error("API 키 선택 중 오류 발생:", e);
// 특정 에러 메시지에 따라 사용자에게 더 명확한 안내 제공
if (e.message && e.message.includes("Requested entity was not found")) {
setError("유효하지 않은 프로젝트/키가 선택되었습니다. 다시 시도해주세요.");
} else {
setError("API 키 선택에 실패했습니다. 다시 시도해주세요.");
}
} finally {
setLoading(false); // 로딩 상태 종료
}
};
/**
* API
* API .
*/
const handleManualSubmit = (e: React.FormEvent) => {
e.preventDefault(); // 폼 기본 제출 동작 방지
if(!manualKey.trim()) {
setError("유효한 API 키를 입력해주세요."); // 빈 값일 경우 에러 메시지
return;
}
onKeySelected(manualKey.trim()); // 유효한 키가 입력되면 콜백 호출
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90 p-4">
<div className="glass-panel p-8 rounded-2xl max-w-md w-full text-center border border-purple-500/30 shadow-2xl shadow-purple-900/20">
<div className="mb-6 flex justify-center">
<div className="p-4 bg-purple-900/30 rounded-full">
<Key className="w-8 h-8 text-purple-400" />
</div>
</div>
<h2 className="text-2xl font-bold text-white mb-2">API </h2>
<p className="text-gray-400 mb-6">
Veo Gemini , Google Cloud API .
</p>
{error && (
<div className="mb-4 p-3 bg-red-900/30 border border-red-500/30 rounded text-red-200 text-sm">
{error}
</div>
)}
{isAiStudio ? (
// AI Studio 환경일 경우 API 키 선택 버튼 제공
<button
onClick={handleSelectKey}
disabled={loading}
className="w-full py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-xl transition-all transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
>
{loading ? '연결 중...' : 'API 키 선택'}
</button>
) : (
// 일반 브라우저 환경일 경우 수동 API 키 입력 폼 제공
<form onSubmit={handleManualSubmit} className="flex flex-col gap-4">
<input
type="password" // 비밀번호 타입으로 입력 값 숨김
value={manualKey}
onChange={(e) => setManualKey(e.target.value)}
placeholder="Gemini API 키 입력"
className="w-full p-3 rounded-xl bg-white/10 border border-white/20 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
<button
type="submit"
className="w-full py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-xl transition-all transform hover:scale-105 shadow-lg"
>
</button>
</form>
)}
<div className="mt-4 text-xs text-gray-500">
<a href="https://ai.google.dev/gemini-api/docs/billing" target="_blank" rel="noreferrer" className="underline hover:text-gray-300">
</a>
</div>
</div>
</div>
);
};
export default ApiKeySelector;

View File

@ -0,0 +1,100 @@
import React from 'react';
interface CaStADLogoProps {
size?: 'sm' | 'md' | 'lg' | 'xl';
showText?: boolean;
className?: string;
}
const sizeClasses = {
sm: { icon: 'w-8 h-8', text: 'text-lg' },
md: { icon: 'w-12 h-12', text: 'text-xl' },
lg: { icon: 'w-16 h-16', text: 'text-2xl' },
xl: { icon: 'w-24 h-24', text: 'text-4xl' },
};
// Main CaStAD Logo with custard pudding icon
export const CaStADLogo: React.FC<CaStADLogoProps> = ({
size = 'md',
showText = true,
className
}) => {
const { icon, text } = sizeClasses[size];
return (
<div className={`flex items-center gap-2 ${className || ''}`}>
{/* Custard Pudding SVG Icon */}
<div className={`${icon} relative`}>
<img
src="/images/castad-logo.svg"
alt="CaStAD"
className="w-full h-full object-contain"
/>
</div>
{showText && (
<div className="flex flex-col">
<span className={`${text} font-bold bg-gradient-to-r from-amber-600 via-amber-500 to-yellow-600 bg-clip-text text-transparent`}>
CaStAD
</span>
<span className="text-[10px] text-muted-foreground tracking-wide">
</span>
</div>
)}
</div>
);
};
// Inline version with custard cream colors
export const CaStADLogoInline: React.FC<CaStADLogoProps> = ({
size = 'md',
className
}) => {
const { icon, text } = sizeClasses[size];
return (
<div className={`flex items-center gap-2 ${className || ''}`}>
{/* Custard emoji as simple icon */}
<span className={`${icon} flex items-center justify-center`}>
<span className="text-2xl">🍮</span>
</span>
<span className={`${text} font-bold bg-gradient-to-r from-amber-600 via-amber-500 to-yellow-600 bg-clip-text text-transparent`}>
CaStAD
</span>
</div>
);
};
// Icon only version
export const CaStADIcon: React.FC<{ size?: 'sm' | 'md' | 'lg' | 'xl'; className?: string }> = ({
size = 'md',
className
}) => {
const { icon } = sizeClasses[size];
return (
<div className={`${icon} ${className || ''}`}>
<img
src="/images/castad-logo.svg"
alt="CaStAD"
className="w-full h-full object-contain"
/>
</div>
);
};
// Text only gradient version
export const CaStADText: React.FC<{ size?: 'sm' | 'md' | 'lg' | 'xl'; className?: string }> = ({
size = 'md',
className
}) => {
const { text } = sizeClasses[size];
return (
<span className={`${text} font-bold bg-gradient-to-r from-amber-600 via-amber-500 to-yellow-600 bg-clip-text text-transparent ${className || ''}`}>
CaStAD
</span>
);
};
export default CaStADLogo;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,133 @@
import React from 'react';
import { useLanguage } from '../src/contexts/LanguageContext';
import { cn } from '../src/lib/utils';
import { Card, CardContent } from '../src/components/ui/card';
import { Progress } from '../src/components/ui/progress';
import { Loader2, Sparkles, Music, Image, Video, CheckCircle } from 'lucide-react';
interface LoadingOverlayProps {
status: string;
message: string;
progress?: number;
}
const STEP_ICONS: Record<string, React.ReactNode> = {
'crawling': <Sparkles className="w-6 h-6" />,
'generating_text': <Sparkles className="w-6 h-6" />,
'generating_audio': <Music className="w-6 h-6" />,
'generating_poster': <Image className="w-6 h-6" />,
'generating_video': <Video className="w-6 h-6" />,
'completed': <CheckCircle className="w-6 h-6 text-green-500" />,
};
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ status, message, progress }) => {
const { t } = useLanguage();
let displayMessage = message;
switch (status) {
case 'crawling':
case 'generating_text':
displayMessage = t('loadingStep1');
break;
case 'generating_audio':
displayMessage = t('loadingStep2').replace('{style}', 'Auto');
break;
case 'generating_poster':
case 'generating_video':
displayMessage = t('loadingStep3');
break;
case 'completed':
displayMessage = t('loadingStep4');
break;
default:
break;
}
const currentProgress = progress || (
status === 'crawling' ? 10 :
status === 'generating_text' ? 25 :
status === 'generating_audio' ? 50 :
status === 'generating_poster' ? 70 :
status === 'generating_video' ? 85 :
status === 'completed' ? 100 : 10
);
const currentIcon = STEP_ICONS[status] || <Sparkles className="w-6 h-6" />;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/95 backdrop-blur-sm">
<Card className="w-full max-w-md mx-4 border-border/50 shadow-2xl">
<CardContent className="pt-8 pb-8 text-center">
{/* Animated Icon */}
<div className="relative w-24 h-24 mx-auto mb-8">
{/* Outer ring */}
<div className="absolute inset-0 rounded-full border-4 border-primary/20 animate-ping" style={{ animationDuration: '2s' }} />
{/* Spinning ring */}
<div className="absolute inset-0 rounded-full border-4 border-transparent border-t-primary border-r-accent animate-spin" style={{ animationDuration: '1.5s' }} />
{/* Center icon */}
<div className="absolute inset-3 rounded-full bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-lg">
<div className="text-primary-foreground animate-pulse">
{currentIcon}
</div>
</div>
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">
{t('loadingTitle')}
</h2>
<p className="text-muted-foreground text-sm mb-6">
{t('loadingSubtitle')}
</p>
{/* Progress Bar */}
<div className="mb-6">
<Progress value={currentProgress} className="h-2" />
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
<span>{t('textStyle') === '자막 스타일' ? '진행률' : 'Progress'}</span>
<span className="font-medium">{currentProgress}%</span>
</div>
</div>
{/* Status Message */}
<div className="flex items-center justify-center gap-2 mb-4">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<p className="text-lg font-semibold gradient-text">
{displayMessage}
</p>
</div>
{/* Steps indicator */}
<div className="flex items-center justify-center gap-2 mb-6">
{['generating_text', 'generating_audio', 'generating_poster', 'completed'].map((step, idx) => {
const isActive = status === step;
const isPast = ['generating_text', 'generating_audio', 'generating_poster', 'generating_video', 'completed'].indexOf(status) >
['generating_text', 'generating_audio', 'generating_poster', 'completed'].indexOf(step);
return (
<div
key={step}
className={cn(
"w-2 h-2 rounded-full transition-all",
isActive && "w-6 bg-primary",
isPast && "bg-primary/60",
!isActive && !isPast && "bg-muted-foreground/30"
)}
/>
);
})}
</div>
<p className="text-xs text-muted-foreground">
{t('loadingWait')}
</p>
</CardContent>
</Card>
</div>
);
};
export default LoadingOverlay;

View File

@ -0,0 +1,555 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../src/contexts/AuthContext';
import { useLanguage } from '../src/contexts/LanguageContext';
import { useTheme, PALETTES, ColorPalette } from '../src/contexts/ThemeContext';
import {
LogOut,
User,
LayoutDashboard,
Menu,
Settings,
Globe,
ChevronDown,
Video,
Shield,
X,
Coins,
AlertCircle,
Sun,
Moon,
Palette,
Check
} from 'lucide-react';
import { CaStADLogo, CaStADLogoInline } from './CaStADLogo';
import { Language } from '../types';
import { cn } from '../src/lib/utils';
import { Button } from '../src/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../src/components/ui/dropdown-menu';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
SheetClose,
} from '../src/components/ui/sheet';
import { Avatar, AvatarFallback } from '../src/components/ui/avatar';
import { Badge } from '../src/components/ui/badge';
import { Separator } from '../src/components/ui/separator';
const LANGUAGES = [
{ code: 'KO', label: '한국어', flag: 'kr' },
{ code: 'EN', label: 'English', flag: 'us' },
{ code: 'JP', label: '日本語', flag: 'jp' },
{ code: 'CN', label: '中文', flag: 'cn' },
{ code: 'TH', label: 'ไทย', flag: 'th' },
{ code: 'VN', label: 'Tiếng Việt', flag: 'vn' },
] as const;
const Navbar: React.FC = () => {
const { user, logout, token } = useAuth();
const { language, setLanguage, t } = useLanguage();
const { theme, palette, toggleTheme, setPalette } = useTheme();
const navigate = useNavigate();
const location = useLocation();
const [isScrolled, setIsScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [credits, setCredits] = useState<number | null>(null);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 10);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// 크레딧 조회
useEffect(() => {
const fetchCredits = async () => {
if (!user || !token) {
setCredits(null);
return;
}
try {
const backendPort = import.meta.env.VITE_BACKEND_PORT || '3001';
const res = await fetch(`http://localhost:${backendPort}/api/credits`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setCredits(data.credits);
}
} catch (err) {
console.error('Failed to fetch credits:', err);
}
};
fetchCredits();
// 주기적으로 업데이트 (30초마다)
const interval = setInterval(fetchCredits, 30000);
return () => clearInterval(interval);
}, [user, token]);
const handleLogout = () => {
logout();
navigate('/login');
setMobileOpen(false);
};
// Hide navbar during server rendering (autoplay mode)
const searchParams = new URLSearchParams(location.search);
if (searchParams.get('autoplay') === 'true') {
return null;
}
const currentLanguage = LANGUAGES.find(l => l.code === language) || LANGUAGES[0];
const NavLink = ({ to, children, icon: Icon }: { to: string; children: React.ReactNode; icon?: React.ElementType }) => {
const isActive = location.pathname === to;
return (
<Link
to={to}
onClick={() => setMobileOpen(false)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
{Icon && <Icon className="w-4 h-4" />}
{children}
</Link>
);
};
return (
<nav
className={cn(
"fixed top-0 left-0 right-0 z-50 transition-all duration-300",
isScrolled
? "bg-background/80 backdrop-blur-xl border-b border-border shadow-sm"
: "bg-background/50 backdrop-blur-md"
)}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link to="/" className="flex items-center group hover:opacity-90 transition-opacity">
<CaStADLogo size="sm" />
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-1">
<NavLink to="/" icon={Video}>
{t('menuCreate')}
</NavLink>
{user && (
<NavLink to="/dashboard" icon={LayoutDashboard}>
{t('menuLibrary')}
</NavLink>
)}
{user?.role === 'admin' && (
<NavLink to="/admin" icon={Shield}>
{t('menuAdmin')}
</NavLink>
)}
</div>
{/* Right Actions */}
<div className="flex items-center gap-2">
{/* Language Switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 text-muted-foreground hover:text-foreground"
>
<span className={`fi fi-${currentLanguage.flag} rounded-sm`} />
<span className="hidden sm:inline text-xs font-medium">
{currentLanguage.code}
</span>
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t('settingLanguage') || 'Language'}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{LANGUAGES.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => setLanguage(lang.code as Language)}
className={cn(
"gap-2 cursor-pointer",
language === lang.code && "bg-accent"
)}
>
<span className={`fi fi-${lang.flag} rounded-sm`} />
<span className="text-sm">{lang.label}</span>
{language === lang.code && (
<Badge variant="secondary" className="ml-auto text-[10px] px-1.5">
</Badge>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Theme Toggle */}
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="w-9 h-9 text-muted-foreground hover:text-foreground"
>
{theme === 'dark' ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
<span className="sr-only"> </span>
</Button>
{/* Palette Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-9 h-9 text-muted-foreground hover:text-foreground"
>
<Palette className="w-4 h-4" />
<span className="sr-only"> </span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel className="text-xs text-muted-foreground">
</DropdownMenuLabel>
<DropdownMenuSeparator />
{(Object.keys(PALETTES) as ColorPalette[]).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => setPalette(key)}
className="gap-2 cursor-pointer"
>
<div className={cn(
"w-5 h-5 rounded-full bg-gradient-to-r",
PALETTES[key].preview
)} />
<span className="text-sm">{PALETTES[key].name}</span>
{palette === key && (
<Check className="w-4 h-4 ml-auto text-primary" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* User Menu / Auth Buttons */}
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 pl-2">
<Avatar className="w-7 h-7">
<AvatarFallback className="bg-primary/10 text-primary text-xs font-semibold">
{user.name?.charAt(0).toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<span className="hidden sm:inline text-sm font-medium max-w-[100px] truncate">
{user.name}
</span>
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">@{user.username}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* 크레딧 표시 */}
{user.role !== 'admin' && credits !== null && (
<>
<div className="px-2 py-2">
<div className={cn(
"flex items-center justify-between px-3 py-2 rounded-lg",
credits <= 0 ? "bg-destructive/10" : credits <= 3 ? "bg-yellow-500/10" : "bg-primary/10"
)}>
<div className="flex items-center gap-2">
<Coins className={cn(
"w-4 h-4",
credits <= 0 ? "text-destructive" : credits <= 3 ? "text-yellow-600" : "text-primary"
)} />
<span className="text-sm font-medium"></span>
</div>
<div className="flex items-center gap-1">
<span className={cn(
"text-lg font-bold",
credits <= 0 ? "text-destructive" : credits <= 3 ? "text-yellow-600" : "text-primary"
)}>
{credits}
</span>
<span className="text-xs text-muted-foreground"></span>
</div>
</div>
{credits <= 3 && (
<Link
to="/credits"
className="flex items-center gap-1.5 mt-2 text-xs text-muted-foreground hover:text-primary transition-colors"
>
<AlertCircle className="w-3 h-3" />
{credits <= 0 ? '크레딧이 부족합니다. 충전 요청하기' : '크레딧이 부족합니다'}
</Link>
)}
</div>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/dashboard" className="flex items-center gap-2">
<LayoutDashboard className="w-4 h-4" />
{t('menuLibrary')}
</Link>
</DropdownMenuItem>
{user.role === 'admin' && (
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/admin" className="flex items-center gap-2">
<Shield className="w-4 h-4" />
{t('menuAdmin')}
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="w-4 h-4 mr-2" />
{t('menuLogout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="hidden md:flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link to="/login">{t('menuLogin')}</Link>
</Button>
<Button size="sm" className="shadow-lg shadow-primary/20" asChild>
<Link to="/register">{t('menuStart')}</Link>
</Button>
</div>
)}
{/* Mobile Menu Button */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild className="md:hidden">
<Button variant="ghost" size="icon" className="shrink-0">
<Menu className="w-5 h-5" />
<span className="sr-only"> </span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px] sm:w-[350px]">
<SheetHeader className="text-left">
<SheetTitle>
<CaStADLogo size="sm" />
</SheetTitle>
</SheetHeader>
<div className="mt-8 flex flex-col gap-2">
{/* Mobile Navigation Links */}
<NavLink to="/" icon={Video}>
{t('menuCreate')}
</NavLink>
{user && (
<NavLink to="/dashboard" icon={LayoutDashboard}>
{t('menuLibrary')}
</NavLink>
)}
{user?.role === 'admin' && (
<NavLink to="/admin" icon={Shield}>
{t('menuAdmin')}
</NavLink>
)}
<Separator className="my-4" />
{/* Mobile User Section */}
{user ? (
<div className="space-y-4">
<div className="flex items-center gap-3 px-3 py-2">
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-primary/10 text-primary font-semibold">
{user.name?.charAt(0).toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{user.name}</p>
<p className="text-xs text-muted-foreground">@{user.username}</p>
</div>
{user.role === 'admin' && (
<Badge variant="secondary" className="text-[10px]">Admin</Badge>
)}
</div>
{/* 모바일 크레딧 표시 */}
{user.role !== 'admin' && credits !== null && (
<div className={cn(
"flex items-center justify-between px-3 py-3 rounded-lg mx-3",
credits <= 0 ? "bg-destructive/10" : credits <= 3 ? "bg-yellow-500/10" : "bg-primary/10"
)}>
<div className="flex items-center gap-2">
<Coins className={cn(
"w-5 h-5",
credits <= 0 ? "text-destructive" : credits <= 3 ? "text-yellow-600" : "text-primary"
)} />
<span className="font-medium"></span>
</div>
<div className="flex items-center gap-1">
<span className={cn(
"text-xl font-bold",
credits <= 0 ? "text-destructive" : credits <= 3 ? "text-yellow-600" : "text-primary"
)}>
{credits}
</span>
<span className="text-sm text-muted-foreground"></span>
</div>
</div>
)}
{user.role !== 'admin' && credits !== null && credits <= 3 && (
<Link
to="/credits"
onClick={() => setMobileOpen(false)}
className="flex items-center justify-center gap-2 mx-3 px-3 py-2 rounded-lg bg-primary/10 text-primary text-sm font-medium hover:bg-primary/20 transition-colors"
>
<Coins className="w-4 h-4" />
</Link>
)}
<Button
variant="outline"
className="w-full justify-start gap-2 text-destructive hover:text-destructive"
onClick={handleLogout}
>
<LogOut className="w-4 h-4" />
{t('menuLogout')}
</Button>
</div>
) : (
<div className="flex flex-col gap-2">
<Button variant="outline" className="w-full" asChild>
<Link to="/login" onClick={() => setMobileOpen(false)}>
{t('menuLogin')}
</Link>
</Button>
<Button className="w-full" asChild>
<Link to="/register" onClick={() => setMobileOpen(false)}>
{t('menuStart')}
</Link>
</Button>
</div>
)}
<Separator className="my-4" />
{/* Mobile Theme Settings */}
<div className="px-3 space-y-4">
{/* Theme Toggle */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{theme === 'dark' ? '다크 모드' : '라이트 모드'}
</span>
<Button
variant="outline"
size="sm"
onClick={toggleTheme}
className="gap-2"
>
{theme === 'dark' ? (
<>
<Sun className="w-4 h-4" />
<span></span>
</>
) : (
<>
<Moon className="w-4 h-4" />
<span></span>
</>
)}
</Button>
</div>
{/* Palette Selection */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">
</p>
<div className="flex gap-2">
{(Object.keys(PALETTES) as ColorPalette[]).map((key) => (
<button
key={key}
onClick={() => setPalette(key)}
className={cn(
"w-8 h-8 rounded-full bg-gradient-to-r transition-all",
PALETTES[key].preview,
palette === key
? "ring-2 ring-offset-2 ring-offset-background ring-primary scale-110"
: "hover:scale-105"
)}
title={PALETTES[key].name}
/>
))}
</div>
</div>
</div>
<Separator className="my-4" />
{/* Mobile Language Selection */}
<div className="px-3">
<p className="text-xs font-medium text-muted-foreground mb-2">
{t('settingLanguage') || 'Language'}
</p>
<div className="grid grid-cols-2 gap-2">
{LANGUAGES.map((lang) => (
<button
key={lang.code}
onClick={() => setLanguage(lang.code as Language)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors",
language === lang.code
? "bg-primary/10 text-primary border border-primary/20"
: "bg-accent/50 hover:bg-accent"
)}
>
<span className={`fi fi-${lang.flag} rounded-sm`} />
<span className="truncate">{lang.label}</span>
</button>
))}
</div>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
</nav>
);
};
export default Navbar;

View File

@ -0,0 +1,234 @@
import React, { useState, useEffect } from 'react';
import { X, ChevronRight, Check } from 'lucide-react';
interface TourStep {
target: string;
title: string;
description: string;
placement?: 'top' | 'bottom' | 'left' | 'right';
}
interface OnboardingTourProps {
steps: TourStep[];
onComplete: () => void;
onSkip: () => void;
}
const OnboardingTour: React.FC<OnboardingTourProps> = ({ steps, onComplete, onSkip }) => {
const [currentStep, setCurrentStep] = useState(0);
const [position, setPosition] = useState({ top: 0, left: 0 });
useEffect(() => {
// Remove previous highlights
document.querySelectorAll('.tour-highlight').forEach(el => {
el.classList.remove('tour-highlight');
});
const updatePosition = () => {
const target = document.querySelector(steps[currentStep].target);
if (!target) {
console.warn(`Tour target not found: ${steps[currentStep].target}`);
// Try again after a short delay
setTimeout(updatePosition, 100);
return;
}
// 팝업을 화면 기준으로 고정 배치 (스크롤에 영향받지 않음)
const viewportWidth = window.innerWidth;
// 모바일/데스크톱 구분
const isMobile = viewportWidth < 768;
let top: number;
let left: number;
if (isMobile) {
// 모바일: 화면 하단 고정
top = 20; // viewport 기준
left = 20;
} else {
// 데스크톱: 화면 우측 상단 고정
top = 20; // viewport 기준 (상단에서 20px)
left = viewportWidth - 340; // 우측에서 340px (카드 너비 320 + 여유 20)
}
setPosition({ top, left });
// Highlight target element
target.classList.add('tour-highlight');
// Scroll into view
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
// Wait for DOM to be ready
const timer = setTimeout(updatePosition, 150);
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition);
return () => {
clearTimeout(timer);
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition);
};
}, [currentStep, steps]);
const handleNext = () => {
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1);
} else {
onComplete();
}
};
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const currentStepData = steps[currentStep];
return (
<>
{/* Overlay - 매우 투명하게 */}
<div className="fixed inset-0 bg-black/5 z-40 animate-in fade-in" />
{/* Tour Card - viewport 기준 고정 위치 */}
<div
className="fixed z-50 w-80 bg-gray-900/98 backdrop-blur-md border-2 border-purple-500 rounded-2xl shadow-2xl animate-in fade-in zoom-in-95"
style={{
top: `${position.top}px`, // viewport 기준
right: '20px', // 우측 고정
maxHeight: 'calc(100vh - 40px)', // 화면 높이에서 여유 40px
overflowY: 'auto'
}}
>
{/* Header */}
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{steps.map((_, idx) => (
<div
key={idx}
className={`h-2 rounded-full transition-all ${
idx === currentStep
? 'w-8 bg-purple-500'
: idx < currentStep
? 'w-2 bg-green-500'
: 'w-2 bg-gray-600'
}`}
/>
))}
</div>
<span className="text-xs text-gray-400 ml-2">
{currentStep + 1} / {steps.length}
</span>
</div>
<button
onClick={onSkip}
className="text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-5">
<h3 className="text-xl font-bold text-white mb-2 flex items-center gap-2">
<span className="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center text-sm">
{currentStep + 1}
</span>
{currentStepData.title}
</h3>
<p className="text-gray-300 text-sm leading-relaxed">
{currentStepData.description}
</p>
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-700 flex items-center justify-between">
<button
onClick={onSkip}
className="text-sm text-gray-400 hover:text-white transition-colors"
>
</button>
<div className="flex gap-2">
{currentStep > 0 && (
<button
onClick={handlePrev}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm font-semibold transition-all"
>
</button>
)}
<button
onClick={handleNext}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-semibold transition-all flex items-center gap-2"
>
{currentStep === steps.length - 1 ? (
<>
<Check className="w-4 h-4" />
</>
) : (
<>
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
{/* CSS for highlight effect */}
<style>{`
.tour-highlight {
position: relative !important;
z-index: 9999 !important;
background-color: white !important;
box-shadow:
0 0 0 6px rgba(168, 85, 247, 1),
0 0 0 12px rgba(168, 85, 247, 0.5),
0 0 40px 0 rgba(168, 85, 247, 0.6),
0 0 80px 0 rgba(168, 85, 247, 0.3) !important;
border-radius: 16px !important;
animation: pulse-highlight 2s cubic-bezier(0.4, 0, 0.6, 1) infinite !important;
outline: none !important;
transform: scale(1.02) !important;
transition: all 0.3s ease !important;
}
.tour-highlight * {
position: relative !important;
z-index: 10000 !important;
}
@keyframes pulse-highlight {
0%, 100% {
box-shadow:
0 0 0 6px rgba(168, 85, 247, 1),
0 0 0 12px rgba(168, 85, 247, 0.5),
0 0 40px 0 rgba(168, 85, 247, 0.6),
0 0 80px 0 rgba(168, 85, 247, 0.3);
transform: scale(1.02);
}
50% {
box-shadow:
0 0 0 8px rgba(168, 85, 247, 1),
0 0 0 16px rgba(168, 85, 247, 0.6),
0 0 60px 0 rgba(168, 85, 247, 0.8),
0 0 100px 0 rgba(168, 85, 247, 0.4);
transform: scale(1.03);
}
}
`}</style>
</>
);
};
export default OnboardingTour;

View File

@ -0,0 +1,157 @@
import React from 'react';
import { GeneratedAssets } from '../types';
import { Play, Clock, Music, Mic, Video, Image as ImageIcon, Download } from 'lucide-react';
/**
* ResultList Props
* @interface ResultListProps
* @property {GeneratedAssets[]} history - ()
* @property {(asset: GeneratedAssets) => void} onSelect -
* @property {string} [currentId] - ID ( , UI )
*/
interface ResultListProps {
history: GeneratedAssets[];
onSelect: (asset: GeneratedAssets) => void;
currentId?: string;
}
/**
*
* AI / , .
*/
const ResultList: React.FC<ResultListProps> = ({ history, onSelect, currentId }) => {
// 파일 강제 다운로드 핸들러
const handleDirectDownload = async (e: React.MouseEvent, url: string, filename: string) => {
e.stopPropagation();
e.preventDefault();
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(blobUrl);
a.remove();
} catch (err) {
console.error("다운로드 실패:", err);
window.open(url, '_blank');
}
};
if (history.length === 0) return null;
return (
<div className="w-full max-w-5xl mx-auto mt-12 mb-20 animate-in slide-in-from-bottom-10 duration-700">
<div className="flex items-center gap-3 mb-6 px-4">
<Clock className="w-5 h-5 text-purple-400" />
<h3 className="text-xl font-bold text-white"> (History)</h3>
<span className="px-2 py-0.5 bg-purple-500/20 text-purple-300 text-xs rounded-full font-mono">{history.length}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 px-4">
{history.map((asset) => (
<div
key={asset.id}
onClick={() => onSelect(asset)}
className={`group relative bg-white/5 border rounded-2xl overflow-hidden cursor-pointer transition-all hover:scale-[1.02] hover:shadow-xl hover:shadow-purple-500/10
${currentId === asset.id ? 'border-purple-500 ring-1 ring-purple-500 bg-white/10' : 'border-white/10 hover:border-purple-500/50'}
`}
>
{/* 포스터 썸네일 */}
<div className="aspect-video bg-black relative overflow-hidden">
{asset.posterUrl ? (
<img
src={asset.posterUrl}
alt={asset.businessName}
className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity"
onError={(e) => {
e.currentTarget.style.display = 'none'; // 이미지 숨김
e.currentTarget.nextElementSibling?.classList.remove('hidden'); // 대체 div 표시 (구조상 별도 처리 필요하지만 간단히 숨김 처리)
// 부모 요소에 배경색이 있으므로 이미지가 숨겨지면 배경색이 보임.
// 더 완벽하게 하려면 state로 관리해야 하나, 여기선 간단히 처리.
}}
/>
) : (
<div className="w-full h-full bg-gray-800 flex items-center justify-center text-gray-500"><ImageIcon /></div>
)}
{/* 이미지 로드 실패 시 보여줄 폴백 (JS로 제어하기보다, 위 onError에서 이미지 숨기면 아래 배경이 보임) */}
<div className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity backdrop-blur-[2px]">
<div className="p-3 rounded-full bg-white/20 backdrop-blur-sm border border-white/30">
<Play className="w-6 h-6 text-white fill-white" />
</div>
</div>
<div className="absolute top-2 left-2 flex gap-1 flex-wrap">
{asset.audioMode === 'Song' ? (
<div className="p-1 bg-purple-600/90 rounded text-white shadow-sm"><Music className="w-3 h-3" /></div>
) : (
<div className="p-1 bg-blue-600/90 rounded text-white shadow-sm"><Mic className="w-3 h-3" /></div>
)}
<div className="px-2 py-0.5 bg-black/60 backdrop-blur-sm rounded text-[10px] text-white font-bold border border-white/10 flex items-center">
{asset.textEffect}
</div>
</div>
</div>
{/* 정보 영역 */}
<div className="p-4">
<h4 className="text-white font-bold truncate mb-1 text-base">{asset.businessName}</h4>
{asset.sourceUrl && (
<a href={asset.sourceUrl} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()} className="text-xs text-blue-400 hover:underline truncate block mb-2 opacity-70 hover:opacity-100">
{asset.sourceUrl}
</a>
)}
<div className="flex items-center justify-between text-xs text-gray-400">
{/* 생성 날짜 및 시간 */}
<span>
{new Date(asset.createdAt).toLocaleDateString()}
</span>
{/* 장르 또는 비디오 타입 표시 */}
<span className="flex items-center gap-1 bg-white/5 px-1.5 py-0.5 rounded">
{asset.audioMode === 'Song' ? (
<>{asset.musicGenre || 'Music'}</>
) : (
<><Mic className="w-3 h-3" /> Narration</>
)}
</span>
</div>
</div>
{/* 하단 버튼 영역 */}
<div className="px-4 pb-4 pt-0 flex gap-2">
{asset.finalVideoPath ? (
<button
onClick={(e) => handleDirectDownload(e, asset.finalVideoPath!, `CastAD_${asset.businessName}_Final.mp4`)}
className="flex-1 py-2 bg-green-600 hover:bg-green-500 rounded-lg text-sm font-bold text-white transition-colors flex items-center justify-center gap-2"
>
<Download className="w-4 h-4" />
</button>
) : null}
<button
onClick={(e) => {
e.stopPropagation();
onSelect(asset);
}}
className={`flex-1 py-2 bg-purple-600 hover:bg-purple-500 rounded-lg text-sm font-bold text-white transition-colors flex items-center justify-center gap-2 ${asset.finalVideoPath ? 'bg-gray-700 hover:bg-gray-600' : ''}`}
>
<Play className="w-4 h-4" /> {asset.finalVideoPath ? '수정/재생성' : '결과 보기 / 다운로드'}
</button>
</div>
</div>
))}
</div>
</div>
);
};
export default ResultList;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,189 @@
import React, { useState, useEffect } from 'react';
import { X, Copy, Check, Share2, Smartphone, Link as LinkIcon } from 'lucide-react';
/**
* ShareModal Props
* @interface ShareModalProps
* @property {string} videoUrl - URL
* @property {string} posterUrl - ( , ) URL
* @property {string} businessName - ( )
* @property {() => void} onClose -
*/
interface ShareModalProps {
videoUrl: string;
posterUrl: string; // 현재 사용되지 않음
businessName: string;
onClose: () => void;
}
/**
*
* . ( , )
*/
const ShareModal: React.FC<ShareModalProps> = ({ videoUrl, businessName, onClose }) => {
const [isGenerating, setIsGenerating] = useState(true); // 공유 링크 생성 중 여부
const [shareLink, setShareLink] = useState(''); // 생성된 공유 링크
const [copied, setCopied] = useState(false); // 링크 복사 성공 여부
const [canShareFile, setCanShareFile] = useState(false); // Web Share API로 파일 공유 가능한지 여부
/**
* Web Share API .
*/
useEffect(() => {
// 고유 ID를 기반으로 목업 공유 링크를 생성합니다. (실제 서비스에서는 백엔드에서 생성)
const uniqueId = Math.random().toString(36).substring(2, 10);
const mockLink = `${window.location.origin}/share/${uniqueId}`; // 실제 앱에서는 호스팅된 비디오 URL이 됩니다.
// 링크 생성 시뮬레이션 (1.5초 후 완료)
const timer = setTimeout(() => {
setShareLink(mockLink);
setIsGenerating(false);
}, 1500);
// Web Share API (파일 공유) 지원 여부 확인
if (navigator.share && navigator.canShare) {
setCanShareFile(true);
}
return () => clearTimeout(timer); // 컴포넌트 언마운트 시 타이머 정리
}, []);
/**
*
*/
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(shareLink);
setCopied(true);
setTimeout(() => setCopied(false), 2000); // 2초 후 복사 상태 초기화
} catch (err) {
console.error('링크 복사 실패:', err);
alert("링크 복사에 실패했습니다. 수동으로 복사해주세요.");
}
};
/**
* API
* Instagram, KakaoTalk .
*/
const handleNativeShare = async () => {
try {
// 비디오 Blob URL을 File 객체로 변환하여 공유 데이터에 포함시킵니다.
const response = await fetch(videoUrl);
const blob = await response.blob();
// 파일 이름은 업체명과 광고로 구성
const file = new File([blob], `${businessName.replace(/\s+/g, '_')}_광고.mp4`, { type: 'video/mp4' });
const shareData = {
title: `${businessName} AI 광고 영상`,
text: 'BizVibe로 제작된 AI 음악 비디오 광고를 확인해보세요!',
files: [file] // 공유할 파일 배열
};
// 파일 공유가 가능한지 다시 확인 후 공유
if (navigator.canShare(shareData)) {
await navigator.share(shareData);
} else {
// 파일 공유가 지원되지 않을 경우, 텍스트와 링크만 공유하는 폴백
await navigator.share({
title: shareData.title,
text: shareData.text,
url: shareLink
});
}
} catch (err) {
console.error('공유 중 오류 발생:', err);
alert("공유 기능 사용 중 오류가 발생했습니다.");
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-in fade-in duration-200"> {/* 모달 배경 및 페이드인 애니메이션 */}
<div className="relative w-full max-w-md mx-4 bg-[#1a1a1d] border border-purple-500/30 rounded-2xl shadow-2xl overflow-hidden">
{/* 모달 헤더 */}
<div className="p-6 border-b border-white/10 flex items-center justify-between bg-white/5">
<h3 className="text-xl font-bold text-white flex items-center gap-2">
<Share2 className="w-5 h-5 text-purple-400" />
</h3>
<button
onClick={onClose} // 모달 닫기 버튼
className="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-6">
{isGenerating ? (
// 링크 생성 중일 때 로딩 스피너 표시
<div className="text-center py-8 space-y-4">
<div className="relative w-16 h-16 mx-auto">
<div className="absolute inset-0 border-4 border-purple-500/30 rounded-full"></div>
<div className="absolute inset-0 border-4 border-t-purple-500 rounded-full animate-spin"></div>
</div>
<p className="text-gray-300 animate-pulse"> ...</p>
</div>
) : (
<>
{/* 고유 링크 섹션 */}
<div className="space-y-2">
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-1">
<LinkIcon className="w-3 h-3" />
</label>
<div className="flex items-center gap-2 p-2 bg-black/50 rounded-xl border border-gray-700">
<input
type="text"
readOnly // 읽기 전용
value={shareLink}
className="flex-1 bg-transparent text-sm text-purple-300 outline-none font-mono"
/>
<button
onClick={handleCopy} // 복사 버튼
className={`p-2 rounded-lg transition-all ${
copied
? 'bg-green-500/20 text-green-400' // 복사 완료 시 초록색 강조
: 'bg-white/10 hover:bg-white/20 text-white'
}`}
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />} {/* 복사 아이콘 변경 */}
</button>
</div>
<p className="text-[10px] text-gray-500">
* . .
</p>
</div>
{/* 네이티브 공유 섹션 */}
{canShareFile && (
<button
onClick={handleNativeShare} // 네이티브 공유 버튼
className="w-full py-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-xl shadow-lg shadow-purple-900/30 transform transition-all hover:scale-[1.02] flex items-center justify-center gap-3"
>
<Smartphone className="w-5 h-5" />
(Instagram, Kakao )
</button>
)}
{/* 소셜 아이콘 목업 (클릭 시 새 탭 열림) */}
<div className="grid grid-cols-3 gap-3 pt-2">
{['Twitter', 'Facebook', 'LinkedIn'].map((platform) => (
<button
key={platform}
className="py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/5 text-gray-400 hover:text-white text-xs font-medium transition-all"
onClick={() => window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent('BizVibe로 만든 AI 음악 비디오를 확인해보세요! ' + shareLink)}`, '_blank')} // 소셜 공유 링크 생성
>
{platform}
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
);
};
export default ShareModal;

View File

@ -0,0 +1,222 @@
import React, { useEffect, useState } from 'react';
import { TransitionEffect } from '../types';
interface SlideshowBackgroundProps {
images: string[];
durationPerImage?: number;
transitionDuration?: number;
effect?: TransitionEffect; // 선택된 효과
}
const EFFECTS_MAP: Record<TransitionEffect, string[]> = {
'Mix': ['fade', 'blur-fade'],
'Zoom': ['zoom-in', 'zoom-out', 'ken-burns-extreme'],
'Slide': ['slide-left', 'slide-right', 'slide-up', 'slide-down'],
'Wipe': ['pixelate-sim', 'flash', 'glitch'] // Wipe 대신 화려한 효과들을 매핑
};
const ALL_EFFECTS = [
'fade',
'slide-left', 'slide-right', 'slide-up', 'slide-down',
'zoom-in', 'zoom-out', 'zoom-spin',
'flip-x', 'flip-y',
'blur-fade',
'glitch',
'ken-burns-extreme',
'pixelate-sim',
'flash'
];
/**
*
*/
const SlideshowBackground: React.FC<SlideshowBackgroundProps> = ({
images,
durationPerImage = 6000,
transitionDuration = 1500,
effect = 'Mix' // 기본값 Mix
}) => {
const [activeIndex, setActiveIndex] = useState(0);
const [currentAnimName, setCurrentAnimName] = useState('fade');
// 효과 매핑 로직
const getNextEffect = () => {
const candidates = EFFECTS_MAP[effect] || EFFECTS_MAP['Mix'];
return candidates[Math.floor(Math.random() * candidates.length)];
};
useEffect(() => {
if (images.length <= 1) return;
// 초기 효과 설정
setCurrentAnimName(getNextEffect());
const interval = setInterval(() => {
const nextIndex = (activeIndex + 1) % images.length;
setCurrentAnimName(getNextEffect()); // 다음 효과 랜덤 선택 (카테고리 내에서)
setActiveIndex(nextIndex);
}, durationPerImage);
return () => clearInterval(interval);
}, [activeIndex, images.length, durationPerImage, effect]);
if (!images || images.length === 0) return <div className="w-full h-full bg-black" />;
return (
<div className="absolute inset-0 w-full h-full overflow-hidden bg-black perspective-1000">
{images.map((src, index) => {
const isActive = index === activeIndex;
const isPrev = index === (activeIndex - 1 + images.length) % images.length;
let effectClass = '';
if (isActive) {
effectClass = `animate-${currentAnimName}-in`;
} else if (isPrev) {
effectClass = `animate-fade-out`;
}
return (
<div
key={`${src}-${index}`}
className={`absolute inset-0 w-full h-full
${isActive ? 'z-20 opacity-100' : isPrev ? 'z-10 opacity-100' : 'z-0 opacity-0'}
${effectClass}
`}
style={{
animationDuration: `${transitionDuration}ms`,
animationFillMode: 'forwards',
transformOrigin: 'center center'
}}
>
<img
src={src}
alt={`Slide ${index}`}
className="w-full h-full object-cover"
style={{ objectPosition: 'center' }}
/>
{/* 텍스트 가독성을 위한 오버레이 */}
<div className="absolute inset-0 bg-black/30 pointer-events-none" />
</div>
);
})}
<style>{`
.perspective-1000 { perspective: 1000px; }
/* === Common Out Animation === */
@keyframes fade-out {
0% { opacity: 1; }
100% { opacity: 0; }
}
.animate-fade-out { animation-name: fade-out; }
/* === 1. Fade === */
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
.animate-fade-in { animation-name: fade-in; }
/* === 2-5. Slides === */
@keyframes slide-left-in {
0% { transform: translateX(100%); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
}
.animate-slide-left-in { animation-name: slide-left-in; }
@keyframes slide-right-in {
0% { transform: translateX(-100%); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
}
.animate-slide-right-in { animation-name: slide-right-in; }
@keyframes slide-up-in {
0% { transform: translateY(100%); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
.animate-slide-up-in { animation-name: slide-up-in; }
@keyframes slide-down-in {
0% { transform: translateY(-100%); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
.animate-slide-down-in { animation-name: slide-down-in; }
/* === 6-8. Zooms === */
@keyframes zoom-in-in {
0% { transform: scale(0.5); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.animate-zoom-in-in { animation-name: zoom-in-in; }
@keyframes zoom-out-in {
0% { transform: scale(1.5); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.animate-zoom-out-in { animation-name: zoom-out-in; }
@keyframes zoom-spin-in {
0% { transform: scale(0) rotate(-180deg); opacity: 0; }
100% { transform: scale(1) rotate(0deg); opacity: 1; }
}
.animate-zoom-spin-in { animation-name: zoom-spin-in; }
/* === 9-10. Flips (3D) === */
@keyframes flip-x-in {
0% { transform: perspective(400px) rotateX(90deg); opacity: 0; }
100% { transform: perspective(400px) rotateX(0deg); opacity: 1; }
}
.animate-flip-x-in { animation-name: flip-x-in; }
@keyframes flip-y-in {
0% { transform: perspective(400px) rotateY(90deg); opacity: 0; }
100% { transform: perspective(400px) rotateY(0deg); opacity: 1; }
}
.animate-flip-y-in { animation-name: flip-y-in; }
/* === 11. Blur Fade === */
@keyframes blur-fade-in {
0% { filter: blur(20px); opacity: 0; transform: scale(1.1); }
100% { filter: blur(0px); opacity: 1; transform: scale(1); }
}
.animate-blur-fade-in { animation-name: blur-fade-in; }
/* === 12. Glitch === */
@keyframes glitch-in {
0% { transform: translate(0); opacity: 0; }
20% { transform: translate(-5px, 5px); opacity: 1; }
40% { transform: translate(-5px, -5px); }
60% { transform: translate(5px, 5px); }
80% { transform: translate(5px, -5px); }
100% { transform: translate(0); opacity: 1; }
}
.animate-glitch-in { animation-name: glitch-in; }
/* === 13. Ken Burns Extreme === */
@keyframes ken-burns-extreme-in {
0% { transform: scale(1.5) translate(10%, 10%); opacity: 0; }
100% { transform: scale(1) translate(0, 0); opacity: 1; }
}
.animate-ken-burns-extreme-in { animation-name: ken-burns-extreme-in; }
/* === 14. Pixelate Simulation === */
@keyframes pixelate-sim-in {
0% { filter: blur(10px) contrast(200%); opacity: 0; }
50% { filter: blur(5px) contrast(150%); opacity: 0.5; }
100% { filter: blur(0) contrast(100%); opacity: 1; }
}
.animate-pixelate-sim-in { animation-name: pixelate-sim-in; }
/* === 15. Flash (Lens Flare Vibe) === */
@keyframes flash-in {
0% { filter: brightness(300%); opacity: 0; }
50% { filter: brightness(200%); opacity: 1; }
100% { filter: brightness(100%); opacity: 1; }
}
.animate-flash-in { animation-name: flash-in; }
`}</style>
</div>
);
};
export default SlideshowBackground;

175
castad-data/deploy.sh Normal file
View File

@ -0,0 +1,175 @@
#!/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'
CYAN='\033[0;36m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BOLD='\033[1m'
NC='\033[0m'
log() { echo -e "${GREEN}[Deploy]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; }
info() { echo -e "${CYAN}[INFO]${NC} $1"; }
# ═══════════════════════════════════════════════════════════════
# 빌드
# ═══════════════════════════════════════════════════════════════
do_build() {
log "프론트엔드 빌드 중..."
cd "$LOCAL_PATH"
npm run build
if [ $? -ne 0 ]; then
error "빌드 실패!"
exit 1
fi
info "빌드 완료"
}
# ═══════════════════════════════════════════════════════════════
# 서버로 파일 전송
# ═══════════════════════════════════════════════════════════════
do_upload() {
log "서버로 파일 전송 중..."
# 제외할 파일/폴더
EXCLUDES="--exclude=node_modules --exclude=.git --exclude=*.log --exclude=pids --exclude=logs --exclude=server/database.sqlite --exclude=server/downloads --exclude=server/temp --exclude=.env"
# rsync로 동기화
rsync -avz --progress $EXCLUDES \
"$LOCAL_PATH/" \
"${SERVER_USER}@${SERVER}:${REMOTE_PATH}/"
if [ $? -ne 0 ]; then
error "파일 전송 실패!"
exit 1
fi
info "파일 전송 완료"
}
# ═══════════════════════════════════════════════════════════════
# 서버에서 설치 및 재시작
# ═══════════════════════════════════════════════════════════════
do_install() {
log "서버에서 의존성 설치 중..."
ssh "${SERVER_USER}@${SERVER}" << 'REMOTE_SCRIPT'
cd /home/castad/castad
# npm 의존성 설치
echo "=== 프론트엔드 의존성 설치 ==="
npm install --legacy-peer-deps --silent
echo "=== 백엔드 의존성 설치 ==="
cd server && npm install --legacy-peer-deps --silent && cd ..
echo "=== 완료 ==="
REMOTE_SCRIPT
if [ $? -ne 0 ]; then
error "서버 설치 실패!"
exit 1
fi
info "서버 설치 완료"
}
do_restart() {
log "서버 재시작 중..."
ssh "${SERVER_USER}@${SERVER}" << 'REMOTE_SCRIPT'
cd /home/castad/castad
./startserver.sh restart
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

38
castad-data/index.html Normal file
View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="ko" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CaStAD - 카스타드 | AI 펜션 마케팅 영상 플랫폼</title>
<meta name="description" content="CaStAD(카스타드) - 펜션, 풀빌라, 오션뷰 숙소를 위한 AI 마케팅 영상 제작 SaaS 플랫폼. 15초만에 인스타그램 릴스, 틱톡, YouTube Shorts용 홍보 영상을 자동 생성합니다." />
<meta name="keywords" content="CaStAD, 카스타드, 펜션 마케팅, AI 영상 제작, 인스타그램 릴스, 틱톡 영상, 유튜브 쇼츠, 숙박업 마케팅, 풀빌라, 오션뷰, 펜션 홍보, SaaS" />
<meta property="og:title" content="CaStAD - 카스타드 | AI 펜션 마케팅 영상 플랫폼" />
<meta property="og:description" content="AI가 만드는 펜션 홍보 영상. 네이버 플레이스 URL만 입력하면 15초만에 인스타 릴스 영상 완성!" />
<meta property="og:image" content="/images/castad-logo.svg" />
<link rel="icon" type="image/svg+xml" href="/images/castad-logo.svg" />
<meta property="og:type" content="website" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"/>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+KR:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script type="importmap">
{
"imports": {
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.554.0",
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0",
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"react": "https://aistudiocdn.com/react@^19.2.0",
"@ffmpeg/ffmpeg": "https://unpkg.com/@ffmpeg/ffmpeg@0.12.10/dist/esm/index.js",
"@ffmpeg/util": "https://unpkg.com/@ffmpeg/util@0.12.1/dist/esm/index.js",
"@ffmpeg/core": "https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm/ffmpeg-core.js",
"dom": "https://aistudiocdn.com/dom@^0.0.3"
}
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

16
castad-data/index.tsx Normal file
View File

@ -0,0 +1,16 @@
/// <reference lib="dom" />
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,5 @@
{
"name": "BizVibe - AI Music Video Generator",
"description": "Transform your business photo and info into a stunning music video with AI-generated Korean ad copy and visuals.",
"requestFramePermissions": []
}

View File

@ -0,0 +1,231 @@
#!/bin/bash
# ==============================================================================
# BizVibe Multi-Domain Deployment & Setup Script
# ==============================================================================
# 이 스크립트는 멀티 도메인 Nginx 환경에 BizVibe 서비스를 안전하게 배포합니다.
# 기존 서비스와의 포트 충돌을 방지하며 Nginx 설정을 자동으로 생성합니다.
# ==============================================================================
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# ------------------------------------------------------------------------------
# 1. 포트 자동 할당 (충돌 방지)
# ------------------------------------------------------------------------------
echo -e "${CYAN}[1] 사용 가능한 포트 확인 중...${NC}"
# 사용 중인 포트 목록 가져오기
USED_PORTS=$(netstat -tuln | grep LISTEN | awk '{print $4}' | awk -F: '{print $NF}' | sort -n | uniq)
# 추천 포트 범위 (3000번대 -> 4000번대 순으로 검색)
# nginx_analysis.txt에 따르면 3000번은 whitedonkey가 사용 중일 수 있으므로 3001부터 시작
START_PORT=3001
END_PORT=4000
TARGET_PORT=""
for port in $(seq $START_PORT $END_PORT); do
if ! echo "$USED_PORTS" | grep -q "^$port$"; then
TARGET_PORT=$port
break
fi
done
if [ -z "$TARGET_PORT" ]; then
echo -e "${RED}❌ 사용 가능한 포트를 찾을 수 없습니다 (3001-4000). 수동으로 설정해주세요.${NC}"
exit 1
fi
echo -e "${GREEN}✅ BizVibe 백엔드 포트로 ${TARGET_PORT}번을 할당했습니다.${NC}"
# ------------------------------------------------------------------------------
# 2. 필수 패키지 설치
# ------------------------------------------------------------------------------
echo -e "${CYAN}[2] 시스템 패키지 업데이트 및 필수 라이브러리 설치...${NC}"
# Node.js, FFmpeg, 한글 폰트 등 필수 패키지 설치
if [ -x "$(command -v apt-get)" ]; then
sudo apt-get update
sudo apt-get install -y ffmpeg fonts-noto-cjk nginx certbot python3-certbot-nginx \
ca-certificates fonts-liberation libasound2t64 libatk-bridge2.0-0 libatk1.0-0 \
libc6 libcairo2 libcups2 libdbus-1-3 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
else
echo -e "${YELLOW}⚠️ apt-get을 찾을 수 없습니다. 패키지 설치를 건너뜁니다.${NC}"
fi
# ------------------------------------------------------------------------------
# 3. 프로젝트 빌드 및 설정
# ------------------------------------------------------------------------------
echo -e "${CYAN}[3] 프로젝트 의존성 설치 및 빌드...${NC}"
# 프론트엔드 의존성
npm install
# 백엔드 의존성
cd server
npm install --legacy-peer-deps
cd ..
# 포트 설정 적용 (vite.config.ts 및 server/index.js 수정 필요 시 환경 변수로 처리 권장)
# 여기서는 .env 파일 생성/수정으로 처리
if [ ! -f .env ]; then
echo "VITE_GEMINI_API_KEY=YOUR_GEMINI_KEY" > .env
echo "SUNO_API_KEY=YOUR_SUNO_KEY" >> .env
echo -e "${YELLOW}⚠️ .env 파일이 생성되었습니다. API 키를 입력해야 합니다.${NC}"
fi
# 프론트엔드 URL 설정 (배포 시 도메인 주소로 설정)
# 이 부분은 아래 도메인 입력 후에 업데이트하도록 이동하거나, 여기서 미리 placeholder로 설정
if ! grep -q "FRONTEND_URL=" .env; then
echo "FRONTEND_URL=http://localhost:3000" >> .env
fi
# 백엔드 포트 환경변수 추가
if grep -q "PORT=" .env; then
sed -i "s/^PORT=.*/PORT=$TARGET_PORT/" .env
else
echo "PORT=$TARGET_PORT" >> .env
fi
# 프론트엔드 빌드 (Vite가 .env의 설정을 참조함)
npm run build
# ------------------------------------------------------------------------------
# 4. PM2 프로세스 관리 (무중단 배포)
# ------------------------------------------------------------------------------
echo -e "${CYAN}[4] PM2로 서비스 실행...${NC}"
# PM2 설치 확인
if ! command -v pm2 &> /dev/null; then
npm install -g pm2
fi
# 기존 프로세스 정리 (이름 기준)
pm2 delete bizvibe-backend 2>/dev/null
# 백엔드 실행 (서버 사이드 렌더링 포함)
# 정적 파일(프론트엔드 dist)도 백엔드(Express)에서 서빙하도록 server/index.js가 수정되어야 함.
# 현재 server/index.js는 API만 제공하므로, Nginx가 프론트엔드를 서빙하고 API는 백엔드로 프록시하는 구조가 적합.
cd server
pm2 start index.js --name "bizvibe-backend" -- --port $TARGET_PORT
cd ..
pm2 save
echo -e "${GREEN}✅ PM2 서비스 시작 완료 (Port: $TARGET_PORT)${NC}"
# ------------------------------------------------------------------------------
# 5. Nginx 가상호스트 설정 (자동 생성)
# ------------------------------------------------------------------------------
echo -e "${CYAN}[5] Nginx 설정 파일 생성...${NC}"
# 사용자에게 도메인 입력 받기
read -p "배포할 도메인 주소를 입력하세요 (예: bizvibe.ktenterprise.net): " DOMAIN_NAME
if [ -z "$DOMAIN_NAME" ]; then
echo -e "${RED}❌ 도메인이 입력되지 않아 Nginx 설정을 건너뜁니다.${NC}"
else
# .env 파일에 FRONTEND_URL 업데이트 (https://도메인)
# 기존 값이 있으면 교체, 없으면 추가
if grep -q "FRONTEND_URL=" .env; then
sed -i "s|^FRONTEND_URL=.*|FRONTEND_URL=https://$DOMAIN_NAME|" .env
else
echo "FRONTEND_URL=https://$DOMAIN_NAME" >> .env
fi
echo -e "${GREEN}✅ .env 파일에 FRONTEND_URL=https://$DOMAIN_NAME 설정 완료${NC}"
# PM2 재시작하여 변경된 환경변수 적용
pm2 restart bizvibe-backend
NGINX_CONF="/etc/nginx/sites-available/$DOMAIN_NAME"
PROJECT_ROOT=$(pwd)
# Nginx 설정 파일 작성
sudo tee $NGINX_CONF > /dev/null <<EOF
server {
listen 80;
server_name $DOMAIN_NAME;
# 대용량 파일 업로드 허용 (영상 생성 요청 시 Base64 데이터 전송 때문)
client_max_body_size 500M;
# 프론트엔드 정적 파일 서빙
location / {
# 로컬(서버 내부) 및 공인 IP 접속은 인증 제외 (비활성화)
# satisfy any;
# allow 127.0.0.1;
# allow 116.125.140.86; # 서버 공인 IP 추가
# deny all;
# 기본 인증 설정 (외부 접속 시) - 비활성화
# auth_basic "Restricted Area";
# auth_basic_user_file /etc/nginx/.htpasswd;
root $PROJECT_ROOT/dist;
index index.html;
try_files \$uri \$uri/ /index.html;
}
# 백엔드 API 프록시
location /api {
proxy_pass http://127.0.0.1:$TARGET_PORT;
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;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_read_timeout 300s; # 긴 API 응답 시간 허용 (Suno 생성 등)
}
# 렌더링/업로드 엔드포인트 프록시
location /render {
proxy_pass http://127.0.0.1:$TARGET_PORT;
proxy_set_header Host \$host;
proxy_read_timeout 300s; # 긴 렌더링 시간 허용
}
location /auth {
proxy_pass http://127.0.0.1:$TARGET_PORT;
proxy_set_header Host \$host;
}
location /oauth2callback {
proxy_pass http://127.0.0.1:$TARGET_PORT;
proxy_set_header Host \$host;
}
}
EOF
# 심볼릭 링크 생성
sudo ln -sfn $NGINX_CONF /etc/nginx/sites-enabled/
# 설정 검증 및 재시작
if sudo nginx -t; then
sudo systemctl reload nginx
echo -e "${GREEN}✅ Nginx 설정 완료! http://$DOMAIN_NAME 에서 접속 가능합니다.${NC}"
# SSL 인증서 발급 여부 확인
read -p "SSL 인증서(Let's Encrypt)를 발급하시겠습니까? (y/n): " INSTALL_SSL
if [[ "$INSTALL_SSL" == "y" || "$INSTALL_SSL" == "Y" ]]; then
sudo certbot --nginx -d $DOMAIN_NAME
fi
else
echo -e "${RED}❌ Nginx 설정 오류 발생. 설정 파일을 확인하세요: $NGINX_CONF${NC}"
fi
fi
echo -e "${CYAN}======================================================${NC}"
echo -e "${GREEN}🎉 배포 완료!${NC}"
echo -e "Backend Port: $TARGET_PORT"
echo -e "Domain: http://$DOMAIN_NAME"
echo -e "${CYAN}======================================================${NC}"

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;
}
}

View File

@ -0,0 +1,164 @@
# ============================================
# CaStAD Nginx Configuration
# ============================================
# 도메인:
# - castad.ktenterprise.net
# - ado2.whitedonkey.kr
# ============================================
# Upstream 설정
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;
listen [::]:80;
server_name castad.ktenterprise.net ado2.whitedonkey.kr;
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 castad.ktenterprise.net ado2.whitedonkey.kr;
# SSL 인증서 (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/castad.ktenterprise.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/castad.ktenterprise.net/privkey.pem;
# SSL 설정
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;
ssl_session_timeout 10m;
ssl_session_tickets off;
# HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# 기타 보안 헤더
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/castad_access.log;
error_log /var/log/nginx/castad_error.log;
# 파일 업로드 크기 제한 (영상 업로드용)
client_max_body_size 500M;
client_body_timeout 300s;
# 정적 파일 캐싱 (프론트엔드 빌드 파일)
root /var/www/castad/dist;
index index.html;
# Gzip 압축
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml;
gzip_comp_level 6;
# 정적 자원 캐싱
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 요청 → Backend
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;
}
# 렌더링 요청 → Backend (긴 타임아웃)
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;
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 임시 파일
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;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 업로드된 에셋
location /uploads/ {
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;
}
# Instagram 서비스 (내부 프록시)
location /instagram-service/ {
internal;
proxy_pass http://castad_instagram/;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
# SPA 라우팅 - 모든 요청을 index.html로
location / {
try_files $uri $uri/ /index.html;
}
# 에러 페이지
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View File

@ -0,0 +1,39 @@
server {
listen 80;
server_name YOUR_DOMAIN_OR_IP; # 예: bizvibe.ktenterprise.net
# 1. 프론트엔드 (React 빌드 결과물)
# 'npm run build'로 생성된 dist 폴더 경로를 지정하세요.
location / {
root /home/ubuntu/projects/bizvibe/dist; # [수정 필요] 실제 프로젝트 경로로 변경
index index.html;
try_files $uri $uri/ /index.html;
}
# 2. 백엔드 API 프록시
# Node.js 서버(포트 3001)로 요청을 전달합니다.
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;
}
}

6600
castad-data/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

59
castad-data/package.json Normal file
View File

@ -0,0 +1,59 @@
{
"name": "castad-ai-marketing-video-platform",
"private": true,
"version": "3.7.0",
"type": "module",
"scripts": {
"dev": "concurrently \"vite\" \"cd server && node index.js\"",
"build": "vite build",
"preview": "vite preview",
"start:server": "cd server && node index.js",
"build:all": "npm install && npm run build && cd server && npm install"
},
"dependencies": {
"@ffmpeg/core": "0.12.6",
"@ffmpeg/ffmpeg": "0.12.10",
"@ffmpeg/util": "0.12.1",
"@google/genai": "^1.30.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"dom": "^0.0.3",
"lottie-react": "^2.4.1",
"lucide-react": "^0.554.0",
"mammoth": "^1.11.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"sonner": "^2.0.7"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.22",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^9.2.1",
"postcss": "^8.5.6",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.18",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

View File

@ -0,0 +1 @@
39498

View File

@ -0,0 +1 @@
39458

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

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,69 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120">
<defs>
<!-- Custard cream gradient -->
<linearGradient id="custardGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#FFF5E1"/>
<stop offset="50%" style="stop-color:#FFE4B5"/>
<stop offset="100%" style="stop-color:#F5C97A"/>
</linearGradient>
<!-- Caramel top gradient -->
<linearGradient id="caramelGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#D4A056"/>
<stop offset="100%" style="stop-color:#B8860B"/>
</linearGradient>
<!-- Bowl/Cup gradient -->
<linearGradient id="bowlGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#8B7355"/>
<stop offset="50%" style="stop-color:#6B4423"/>
<stop offset="100%" style="stop-color:#4A2C17"/>
</linearGradient>
<!-- Shine effect -->
<linearGradient id="shineGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FFFFFF;stop-opacity:0.6"/>
<stop offset="100%" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<!-- Drop shadow -->
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#000000" flood-opacity="0.2"/>
</filter>
</defs>
<!-- Ramekin/Bowl base -->
<ellipse cx="60" cy="95" rx="40" ry="8" fill="#3D2314" opacity="0.3"/>
<!-- Bowl body -->
<path d="M20 55 L25 90 Q60 100 95 90 L100 55 Q100 50 95 48 L25 48 Q20 50 20 55" fill="url(#bowlGradient)" filter="url(#shadow)"/>
<!-- Bowl rim -->
<ellipse cx="60" cy="48" rx="38" ry="8" fill="#8B7355"/>
<ellipse cx="60" cy="48" rx="35" ry="6" fill="#6B4423"/>
<!-- Custard cream -->
<ellipse cx="60" cy="46" rx="32" ry="5" fill="url(#custardGradient)"/>
<!-- Caramel top layer -->
<ellipse cx="60" cy="44" rx="28" ry="4" fill="url(#caramelGradient)"/>
<!-- Caramelized sugar drizzle -->
<path d="M40 43 Q50 41 60 43 Q70 45 80 43" stroke="#8B4513" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<!-- Shine on custard -->
<ellipse cx="48" cy="42" rx="8" ry="2" fill="url(#shineGradient)"/>
<!-- Steam wisps -->
<path d="M45 35 Q43 28 47 22" stroke="#D4A056" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.5">
<animate attributeName="d" dur="2s" repeatCount="indefinite" values="M45 35 Q43 28 47 22;M45 35 Q47 28 43 22;M45 35 Q43 28 47 22"/>
<animate attributeName="opacity" dur="2s" repeatCount="indefinite" values="0.5;0.3;0.5"/>
</path>
<path d="M60 33 Q58 26 62 20" stroke="#D4A056" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.6">
<animate attributeName="d" dur="2.5s" repeatCount="indefinite" values="M60 33 Q58 26 62 20;M60 33 Q62 26 58 20;M60 33 Q58 26 62 20"/>
<animate attributeName="opacity" dur="2.5s" repeatCount="indefinite" values="0.6;0.3;0.6"/>
</path>
<path d="M75 35 Q73 28 77 22" stroke="#D4A056" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.4">
<animate attributeName="d" dur="3s" repeatCount="indefinite" values="M75 35 Q73 28 77 22;M75 35 Q77 28 73 22;M75 35 Q73 28 77 22"/>
<animate attributeName="opacity" dur="3s" repeatCount="indefinite" values="0.4;0.2;0.4"/>
</path>
<!-- "C" letter integrated into design (subtle) -->
<text x="60" y="75" font-family="Georgia, serif" font-size="20" font-weight="bold" fill="#FFF5E1" text-anchor="middle" opacity="0.9">C</text>
</svg>

After

Width:  |  Height:  |  Size: 3.4 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
castad-data/saas.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

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
};

897
castad-data/server/db.js Normal file
View File

@ -0,0 +1,897 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const bcrypt = require('bcrypt');
const DB_PATH = path.join(__dirname, 'database.sqlite');
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error('데이터베이스 연결 실패:', err.message);
} else {
console.log('SQLite 데이터베이스에 연결되었습니다.');
}
});
// 테이블 초기화
db.serialize(() => {
// Users 테이블
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE,
password TEXT NOT NULL,
name TEXT,
phone TEXT,
role TEXT DEFAULT 'user',
approved INTEGER DEFAULT 0,
email_verified INTEGER DEFAULT 0,
verification_token TEXT,
reset_token TEXT,
reset_token_expiry DATETIME,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// 기존 테이블에 새 컬럼 추가 (이미 존재하면 무시)
db.run("ALTER TABLE users ADD COLUMN email TEXT UNIQUE", (err) => {});
db.run("ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0", (err) => {});
db.run("ALTER TABLE users ADD COLUMN verification_token TEXT", (err) => {});
db.run("ALTER TABLE users ADD COLUMN reset_token TEXT", (err) => {});
db.run("ALTER TABLE users ADD COLUMN reset_token_expiry DATETIME", (err) => {});
// OAuth 컬럼 추가
db.run("ALTER TABLE users ADD COLUMN oauth_provider TEXT", (err) => {});
db.run("ALTER TABLE users ADD COLUMN oauth_provider_id TEXT", (err) => {});
db.run("ALTER TABLE users ADD COLUMN profile_image TEXT", (err) => {});
// 크레딧 컬럼 추가 (무료 플랜 기본값 10)
db.run("ALTER TABLE users ADD COLUMN credits INTEGER DEFAULT 10", (err) => {});
// 구독 플랜 컬럼 추가 (free, basic, pro, business)
db.run("ALTER TABLE users ADD COLUMN plan_type TEXT DEFAULT 'free'", (err) => {});
// 최대 펜션 수 (free/basic: 1, pro: 5, business: unlimited)
db.run("ALTER TABLE users ADD COLUMN max_pensions INTEGER DEFAULT 1", (err) => {});
// 월간 크레딧 한도 (플랜별 다름, 무료=10)
db.run("ALTER TABLE users ADD COLUMN monthly_credits INTEGER DEFAULT 10", (err) => {});
// 구독 시작일
db.run("ALTER TABLE users ADD COLUMN subscription_started_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
)`);
// ============================================
// 펜션/브랜드 프로필 테이블 (다중 펜션 지원)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS pension_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
is_default INTEGER DEFAULT 0,
brand_name TEXT,
brand_name_en TEXT,
region TEXT,
address TEXT,
pension_types TEXT,
target_customers TEXT,
key_features TEXT,
nearby_attractions TEXT,
booking_url TEXT,
homepage_url TEXT,
kakao_channel TEXT,
instagram_handle TEXT,
languages TEXT DEFAULT 'KO',
price_range TEXT,
description TEXT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// 펜션별 is_default 컬럼 추가 (기존 테이블용)
db.run("ALTER TABLE pension_profiles ADD COLUMN is_default INTEGER DEFAULT 0", (err) => {
// 이미 존재하면 에러가 발생하므로 무시
});
// 펜션별 YouTube 플레이리스트 ID 직접 연결
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(`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 분석 데이터 캐시 테이블 (펜션별)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS youtube_analytics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pension_id INTEGER NOT NULL,
playlist_id TEXT NOT NULL,
date DATE NOT NULL,
views INTEGER DEFAULT 0,
playlist_starts INTEGER DEFAULT 0,
average_time_in_playlist REAL DEFAULT 0,
estimated_minutes_watched REAL DEFAULT 0,
subscribers_gained INTEGER DEFAULT 0,
likes INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
shares INTEGER DEFAULT 0,
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE CASCADE,
UNIQUE(pension_id, date)
)`);
// 펜션별 월간 요약 통계 테이블
db.run(`CREATE TABLE IF NOT EXISTS pension_monthly_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pension_id INTEGER NOT NULL,
year_month TEXT NOT NULL,
total_views INTEGER DEFAULT 0,
total_videos INTEGER DEFAULT 0,
total_watch_time REAL DEFAULT 0,
avg_view_duration REAL DEFAULT 0,
top_video_id TEXT,
growth_rate REAL DEFAULT 0,
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE CASCADE,
UNIQUE(pension_id, year_month)
)`);
// ============================================
// YouTube OAuth 토큰 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS youtube_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
google_user_id TEXT,
google_email TEXT,
youtube_channel_id TEXT,
youtube_channel_title TEXT,
access_token TEXT,
refresh_token TEXT,
token_expiry DATETIME,
scopes TEXT,
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// ============================================
// YouTube 업로드 설정 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS youtube_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
default_privacy TEXT DEFAULT 'private',
default_category_id TEXT DEFAULT '19',
default_tags TEXT,
default_hashtags TEXT,
auto_upload INTEGER DEFAULT 0,
upload_timing TEXT DEFAULT 'manual',
scheduled_day TEXT,
scheduled_time TEXT,
default_playlist_id TEXT,
notify_on_upload INTEGER DEFAULT 1,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// ============================================
// YouTube 플레이리스트 캐시 테이블 (펜션별 연결 지원)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS youtube_playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pension_id INTEGER,
playlist_id TEXT NOT NULL,
title TEXT,
item_count INTEGER DEFAULT 0,
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE SET NULL,
UNIQUE(user_id, playlist_id)
)`);
// 플레이리스트에 pension_id 컬럼 추가 (기존 테이블용)
db.run("ALTER TABLE youtube_playlists ADD COLUMN pension_id INTEGER", (err) => {
// 이미 존재하면 에러가 발생하므로 무시
});
// ============================================
// 업로드 히스토리 테이블 (펜션별 연결 지원)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS upload_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pension_id INTEGER,
history_id INTEGER,
youtube_video_id TEXT,
youtube_url TEXT,
title TEXT,
privacy_status TEXT,
playlist_id TEXT,
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'completed',
error_message TEXT,
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
)`);
// 업로드 히스토리에 pension_id 컬럼 추가 (기존 테이블용)
db.run("ALTER TABLE upload_history ADD COLUMN pension_id INTEGER", (err) => {
// 이미 존재하면 에러가 발생하므로 무시
});
// ============================================
// Instagram 연결 정보 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS instagram_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
instagram_username TEXT NOT NULL,
encrypted_password TEXT NOT NULL,
encrypted_session TEXT,
is_active INTEGER DEFAULT 1,
last_login_at DATETIME,
two_factor_required INTEGER DEFAULT 0,
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// ============================================
// Instagram 업로드 설정 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS instagram_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
auto_upload INTEGER DEFAULT 0,
upload_as_reel INTEGER DEFAULT 1,
default_caption_template TEXT,
default_hashtags TEXT,
max_uploads_per_week INTEGER DEFAULT 1,
notify_on_upload INTEGER DEFAULT 1,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// ============================================
// Instagram 업로드 히스토리 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS instagram_upload_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pension_id INTEGER,
history_id INTEGER,
instagram_media_id TEXT,
instagram_post_code TEXT,
permalink TEXT,
caption TEXT,
upload_type TEXT DEFAULT 'reel',
status TEXT DEFAULT 'pending',
error_message TEXT,
retry_count INTEGER DEFAULT 0,
uploaded_at DATETIME,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
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
)`);
// ============================================
// TikTok 연결 정보 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS tiktok_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
open_id TEXT NOT NULL,
display_name TEXT,
avatar_url TEXT,
follower_count INTEGER DEFAULT 0,
following_count INTEGER DEFAULT 0,
access_token TEXT NOT NULL,
refresh_token TEXT,
token_expiry DATETIME,
scopes TEXT,
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// ============================================
// TikTok 업로드 설정 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS tiktok_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
default_privacy TEXT DEFAULT 'SELF_ONLY',
disable_duet INTEGER DEFAULT 0,
disable_comment INTEGER DEFAULT 0,
disable_stitch INTEGER DEFAULT 0,
auto_upload INTEGER DEFAULT 0,
upload_to_inbox INTEGER DEFAULT 1,
default_hashtags TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// ============================================
// TikTok 업로드 히스토리 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS tiktok_upload_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pension_id INTEGER,
history_id INTEGER,
publish_id TEXT,
video_id TEXT,
title TEXT,
privacy_level TEXT DEFAULT 'SELF_ONLY',
status TEXT DEFAULT 'pending',
error_message TEXT,
uploaded_at DATETIME,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
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
)`);
// ============================================
// 플랫폼 통합 통계 테이블 (YouTube, Instagram, TikTok)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS platform_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pension_id INTEGER,
platform TEXT NOT NULL,
date DATE NOT NULL,
views INTEGER DEFAULT 0,
likes INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
shares INTEGER DEFAULT 0,
followers_gained INTEGER DEFAULT 0,
impressions INTEGER DEFAULT 0,
reach INTEGER DEFAULT 0,
engagement_rate REAL DEFAULT 0,
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE SET NULL,
UNIQUE(user_id, pension_id, platform, date)
)`);
// ============================================
// 시스템 활동 로그 테이블 (어드민 분석용)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS activity_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action_type TEXT NOT NULL,
action_detail TEXT,
ip_address TEXT,
user_agent TEXT,
metadata TEXT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
)`);
// ============================================
// 시스템 통계 스냅샷 테이블 (일별 집계)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS system_stats_daily (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE UNIQUE NOT NULL,
total_users INTEGER DEFAULT 0,
new_users INTEGER DEFAULT 0,
active_users INTEGER DEFAULT 0,
total_videos_generated INTEGER DEFAULT 0,
total_uploads INTEGER DEFAULT 0,
youtube_uploads INTEGER DEFAULT 0,
instagram_uploads INTEGER DEFAULT 0,
tiktok_uploads INTEGER DEFAULT 0,
total_credits_used INTEGER DEFAULT 0,
avg_generation_time REAL DEFAULT 0,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// ============================================
// 사용자 에셋 테이블 (이미지, 오디오, 비디오)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS user_assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pension_id INTEGER,
history_id INTEGER,
asset_type TEXT NOT NULL,
source_type TEXT NOT NULL,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER DEFAULT 0,
mime_type TEXT,
thumbnail_path TEXT,
duration REAL,
width INTEGER,
height INTEGER,
metadata TEXT,
is_deleted INTEGER DEFAULT 0,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
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
)`);
// 사용자별 스토리지 한도 컬럼 추가 (MB 단위, 기본 500MB)
db.run("ALTER TABLE users ADD COLUMN storage_limit INTEGER DEFAULT 500", (err) => {});
// 현재 사용 용량 (캐시)
db.run("ALTER TABLE users ADD COLUMN storage_used INTEGER DEFAULT 0", (err) => {});
// ============================================
// 크레딧 요청 테이블
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS credit_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
requested_credits INTEGER DEFAULT 10,
status TEXT DEFAULT 'pending',
reason TEXT,
admin_note TEXT,
processed_by INTEGER,
processed_at DATETIME,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(processed_by) REFERENCES users(id) ON DELETE SET NULL
)`);
// ============================================
// 크레딧 히스토리 테이블 (변동 내역 추적)
// ============================================
db.run(`CREATE TABLE IF NOT EXISTS credit_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
amount INTEGER NOT NULL,
type TEXT NOT NULL,
description TEXT,
balance_after INTEGER,
related_request_id INTEGER,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
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 대신 별도 실행)
// SQLite는 IF NOT EXISTS 컬럼 추가를 지원하지 않으므로, 에러를 무시하는 방식으로 처리하거나 스키마 버전을 관리해야 함.
// 여기서는 간단히 컬럼 추가 시도 후 에러 무시 패턴을 사용.
db.run("ALTER TABLE users ADD COLUMN business_name TEXT", (err) => {
// 이미 존재하면 에러가 발생하므로 무시
});
db.run("ALTER TABLE history ADD COLUMN final_video_path TEXT", (err) => {
// 이미 존재하면 에러가 발생하므로 무시
});
db.run("ALTER TABLE history ADD COLUMN poster_path TEXT", (err) => {
// 이미 존재하면 에러가 발생하므로 무시
});
db.run("ALTER TABLE history ADD COLUMN pension_id INTEGER", (err) => {
// 이미 존재하면 에러가 발생하므로 무시
});
// History 테이블
db.run(`CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
business_name TEXT,
details TEXT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
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 adminPassword = 'admin123'; // 초기 비밀번호
db.get("SELECT * FROM users WHERE username = ?", [adminUsername], (err, row) => {
if (!row) {
const salt = bcrypt.genSaltSync(10);
const hash = bcrypt.hashSync(adminPassword, salt);
db.run(`INSERT INTO users (username, password, name, phone, role, approved, credits)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[adminUsername, hash, 'Super Admin', '000-0000-0000', 'admin', 1, 999999],
(err) => {
if (err) console.error("초기 관리자 생성 실패:", err);
else console.log(`초기 관리자 계정 생성 완료. (ID: ${adminUsername}, PW: ${adminPassword})`);
});
} else if (row.role === 'admin' && (row.credits === null || row.credits < 999999)) {
// 기존 관리자에게 무제한 크레딧 부여
db.run("UPDATE users SET credits = 999999 WHERE id = ?", [row.id]);
}
});
});
module.exports = db;

View File

@ -0,0 +1,237 @@
/**
* Email Service using Resend
*
* 환경변수 필요:
* - RESEND_API_KEY: Resend API (https://resend.com에서 발급)
* - RESEND_FROM_EMAIL: 발신 이메일 (도메인 인증 전에는 'onboarding@resend.dev' 사용)
* - FRONTEND_URL: 프론트엔드 URL (인증 링크용)
*/
const { Resend } = require('resend');
// Resend 인스턴스 - API 키가 없으면 null (이메일 기능 비활성화)
let resend = null;
const RESEND_API_KEY = process.env.RESEND_API_KEY;
if (RESEND_API_KEY && RESEND_API_KEY !== 'your-resend-api-key') {
resend = new Resend(RESEND_API_KEY);
console.log('📧 이메일 서비스 활성화됨 (Resend)');
} else {
console.warn('⚠️ 이메일 서비스 비활성화됨: RESEND_API_KEY가 설정되지 않았습니다.');
console.warn(' 이메일 인증 기능을 사용하려면 .env에 RESEND_API_KEY를 설정하세요.');
}
// 기본 발신 이메일 (도메인 미인증 시 Resend 기본 도메인 사용)
const FROM_EMAIL = process.env.RESEND_FROM_EMAIL || 'CastAD <onboarding@resend.dev>';
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
/**
* 이메일 인증 메일 발송
*/
async function sendVerificationEmail(to, name, verificationToken) {
if (!resend) {
console.warn('이메일 발송 건너뜀 (서비스 비활성화):', to);
return { success: false, error: '이메일 서비스가 설정되지 않았습니다', disabled: true };
}
const verifyUrl = `${FRONTEND_URL}/verify-email?token=${verificationToken}`;
try {
const { data, error } = await resend.emails.send({
from: FROM_EMAIL,
to: [to],
subject: '[CastAD] 이메일 인증을 완료해주세요',
html: `
<div style="font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif; max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<div style="text-align: center; margin-bottom: 40px;">
<h1 style="color: #6366f1; margin: 0;">CastAD</h1>
<p style="color: #64748b; margin-top: 8px;">AI 펜션 마케팅 영상 제작</p>
</div>
<div style="background: #f8fafc; border-radius: 12px; padding: 32px;">
<h2 style="margin: 0 0 16px 0; color: #1e293b;">안녕하세요, ${name || '고객'}!</h2>
<p style="color: #475569; line-height: 1.6; margin: 0 0 24px 0;">
CastAD 회원가입을 환영합니다. 아래 버튼을 클릭하여 이메일 인증을 완료해주세요.
</p>
<div style="text-align: center; margin: 32px 0;">
<a href="${verifyUrl}"
style="display: inline-block; background: #6366f1; color: white; padding: 14px 32px; border-radius: 8px; text-decoration: none; font-weight: 600;">
이메일 인증하기
</a>
</div>
<p style="color: #64748b; font-size: 14px; margin: 24px 0 0 0;">
버튼이 작동하지 않으면 아래 링크를 브라우저에 복사해주세요:<br>
<a href="${verifyUrl}" style="color: #6366f1; word-break: break-all;">${verifyUrl}</a>
</p>
</div>
<div style="text-align: center; margin-top: 32px; color: #94a3b8; font-size: 12px;">
<p> 이메일은 CastAD 회원가입 요청으로 발송되었습니다.</p>
<p>본인이 요청하지 않았다면 이메일을 무시해주세요.</p>
</div>
</div>
`
});
if (error) {
console.error('이메일 발송 실패:', error);
return { success: false, error: error.message };
}
console.log('인증 이메일 발송 완료:', data.id);
return { success: true, id: data.id };
} catch (err) {
console.error('이메일 발송 예외:', err);
return { success: false, error: err.message };
}
}
/**
* 비밀번호 재설정 이메일 발송
*/
async function sendPasswordResetEmail(to, name, resetToken) {
if (!resend) {
console.warn('이메일 발송 건너뜀 (서비스 비활성화):', to);
return { success: false, error: '이메일 서비스가 설정되지 않았습니다', disabled: true };
}
const resetUrl = `${FRONTEND_URL}/reset-password?token=${resetToken}`;
try {
const { data, error } = await resend.emails.send({
from: FROM_EMAIL,
to: [to],
subject: '[CastAD] 비밀번호 재설정 안내',
html: `
<div style="font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif; max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<div style="text-align: center; margin-bottom: 40px;">
<h1 style="color: #6366f1; margin: 0;">CastAD</h1>
<p style="color: #64748b; margin-top: 8px;">AI 펜션 마케팅 영상 제작</p>
</div>
<div style="background: #f8fafc; border-radius: 12px; padding: 32px;">
<h2 style="margin: 0 0 16px 0; color: #1e293b;">비밀번호 재설정</h2>
<p style="color: #475569; line-height: 1.6; margin: 0 0 24px 0;">
${name || '고객'}, 비밀번호 재설정을 요청하셨습니다.<br>
아래 버튼을 클릭하여 비밀번호를 설정해주세요.
</p>
<div style="text-align: center; margin: 32px 0;">
<a href="${resetUrl}"
style="display: inline-block; background: #6366f1; color: white; padding: 14px 32px; border-radius: 8px; text-decoration: none; font-weight: 600;">
비밀번호 재설정하기
</a>
</div>
<div style="background: #fef3c7; border-radius: 8px; padding: 16px; margin: 24px 0;">
<p style="color: #92400e; font-size: 14px; margin: 0;">
링크는 1시간 동안만 유효합니다.
</p>
</div>
<p style="color: #64748b; font-size: 14px; margin: 0;">
버튼이 작동하지 않으면 아래 링크를 브라우저에 복사해주세요:<br>
<a href="${resetUrl}" style="color: #6366f1; word-break: break-all;">${resetUrl}</a>
</p>
</div>
<div style="text-align: center; margin-top: 32px; color: #94a3b8; font-size: 12px;">
<p>본인이 비밀번호 재설정을 요청하지 않았다면 이메일을 무시해주세요.</p>
<p>계정은 안전하게 보호됩니다.</p>
</div>
</div>
`
});
if (error) {
console.error('비밀번호 재설정 이메일 발송 실패:', error);
return { success: false, error: error.message };
}
console.log('비밀번호 재설정 이메일 발송 완료:', data.id);
return { success: true, id: data.id };
} catch (err) {
console.error('이메일 발송 예외:', err);
return { success: false, error: err.message };
}
}
/**
* 환영 이메일 발송 (인증 완료 )
*/
async function sendWelcomeEmail(to, name) {
if (!resend) {
console.warn('이메일 발송 건너뜀 (서비스 비활성화):', to);
return { success: false, error: '이메일 서비스가 설정되지 않았습니다', disabled: true };
}
try {
const { data, error } = await resend.emails.send({
from: FROM_EMAIL,
to: [to],
subject: '[CastAD] 가입을 환영합니다! 🎉',
html: `
<div style="font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif; max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<div style="text-align: center; margin-bottom: 40px;">
<h1 style="color: #6366f1; margin: 0;">CastAD</h1>
<p style="color: #64748b; margin-top: 8px;">AI 펜션 마케팅 영상 제작</p>
</div>
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); border-radius: 12px; padding: 32px; color: white;">
<h2 style="margin: 0 0 16px 0;">🎉 환영합니다, ${name || '고객'}!</h2>
<p style="line-height: 1.6; margin: 0; opacity: 0.9;">
CastAD 가입이 완료되었습니다.<br>
이제 AI가 만드는 펜션 마케팅 영상을 경험해보세요!
</p>
</div>
<div style="margin-top: 32px; padding: 24px; background: #f8fafc; border-radius: 12px;">
<h3 style="margin: 0 0 16px 0; color: #1e293b;">시작하기</h3>
<ul style="color: #475569; line-height: 2; padding-left: 20px; margin: 0;">
<li>펜션 정보를 등록하세요</li>
<li>사진을 업로드하고 AI 영상을 생성하세요</li>
<li>YouTube에 바로 업로드하세요</li>
</ul>
<div style="text-align: center; margin-top: 24px;">
<a href="${FRONTEND_URL}/app"
style="display: inline-block; background: #6366f1; color: white; padding: 14px 32px; border-radius: 8px; text-decoration: none; font-weight: 600;">
CastAD 시작하기
</a>
</div>
</div>
<div style="text-align: center; margin-top: 32px; color: #94a3b8; font-size: 12px;">
<p>문의사항이 있으시면 언제든 연락주세요.</p>
</div>
</div>
`
});
if (error) {
console.error('환영 이메일 발송 실패:', error);
return { success: false, error: error.message };
}
return { success: true, id: data.id };
} catch (err) {
console.error('이메일 발송 예외:', err);
return { success: false, error: err.message };
}
}
/**
* 이메일 서비스 활성화 여부 확인
*/
function isEmailServiceEnabled() {
return resend !== null;
}
module.exports = {
sendVerificationEmail,
sendPasswordResetEmail,
sendWelcomeEmail,
isEmailServiceEnabled
};

File diff suppressed because it is too large Load Diff

7742
castad-data/server/index.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,243 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
암호화 서비스
Fernet 대칭 암호화를 사용하여 Instagram 비밀번호와 세션 데이터를
안전하게 암호화/복호화합니다.
Fernet 특징:
- AES-128-CBC 암호화
- HMAC-SHA256 무결성 검증
- 타임스탬프 포함 (TTL 검증 가능)
- URL-safe base64 인코딩
사용 예시:
encryptor = EncryptionService()
encrypted = encryptor.encrypt("비밀번호")
decrypted = encryptor.decrypt(encrypted)
"""
import os
import json
import base64
import logging
from typing import Optional, Any, Dict
from cryptography.fernet import Fernet, InvalidToken
logger = logging.getLogger(__name__)
class EncryptionService:
"""
Fernet 기반 암호화/복호화 서비스
비밀번호, 세션 데이터 민감한 정보를 안전하게 저장하기 위한
암호화 기능을 제공합니다.
Attributes:
_cipher: Fernet 암호화 인스턴스
_key: 암호화 (bytes)
Example:
>>> service = EncryptionService()
>>> encrypted = service.encrypt("my_password")
>>> decrypted = service.decrypt(encrypted)
>>> print(decrypted) # "my_password"
"""
def __init__(self, key: Optional[str] = None):
"""
암호화 서비스 초기화
Args:
key: Base64 인코딩된 Fernet (32바이트)
None이면 생성
Raises:
ValueError: 유효하지 않은 형식
"""
if key:
try:
# Base64 문자열을 바이트로 디코딩
self._key = key.encode() if isinstance(key, str) else key
self._cipher = Fernet(self._key)
logger.info("암호화 서비스 초기화 완료 (기존 키 사용)")
except Exception as e:
raise ValueError(f"유효하지 않은 암호화 키입니다: {e}")
else:
# 새 키 생성
self._key = Fernet.generate_key()
self._cipher = Fernet(self._key)
logger.warning("새 암호화 키가 생성되었습니다. 환경변수에 저장하세요!")
def get_key_string(self) -> str:
"""
암호화 키를 문자열로 반환
환경변수에 저장할 있는 형태로 키를 반환합니다.
Returns:
Base64 인코딩된 문자열
"""
return self._key.decode() if isinstance(self._key, bytes) else self._key
def encrypt(self, data: str) -> str:
"""
문자열 데이터 암호화
Args:
data: 암호화할 평문 문자열
Returns:
Base64 인코딩된 암호문
Raises:
ValueError: 데이터
"""
if not data:
raise ValueError("암호화할 데이터가 없습니다")
try:
# 문자열을 UTF-8 바이트로 변환 후 암호화
encrypted_bytes = self._cipher.encrypt(data.encode('utf-8'))
# Base64 문자열로 반환
return encrypted_bytes.decode('utf-8')
except Exception as e:
logger.error(f"암호화 실패: {e}")
raise
def decrypt(self, encrypted_data: str) -> str:
"""
암호화된 데이터 복호화
Args:
encrypted_data: Base64 인코딩된 암호문
Returns:
복호화된 평문 문자열
Raises:
ValueError: 데이터 또는 유효하지 않은 암호문
InvalidToken: 잘못된 토큰 또는 불일치
"""
if not encrypted_data:
raise ValueError("복호화할 데이터가 없습니다")
try:
# Base64 문자열을 바이트로 변환 후 복호화
encrypted_bytes = encrypted_data.encode('utf-8')
decrypted_bytes = self._cipher.decrypt(encrypted_bytes)
return decrypted_bytes.decode('utf-8')
except InvalidToken:
logger.error("복호화 실패: 유효하지 않은 토큰 또는 키 불일치")
raise ValueError("암호화 키가 올바르지 않거나 데이터가 손상되었습니다")
except Exception as e:
logger.error(f"복호화 실패: {e}")
raise
def encrypt_json(self, data: Dict[str, Any]) -> str:
"""
JSON 객체 암호화
딕셔너리를 JSON 문자열로 변환 암호화합니다.
세션 데이터 저장에 유용합니다.
Args:
data: 암호화할 딕셔너리 데이터
Returns:
암호화된 문자열
"""
json_string = json.dumps(data, ensure_ascii=False)
return self.encrypt(json_string)
def decrypt_json(self, encrypted_data: str) -> Dict[str, Any]:
"""
암호화된 JSON 데이터 복호화
암호문을 복호화하여 딕셔너리로 파싱합니다.
Args:
encrypted_data: 암호화된 문자열
Returns:
복호화된 딕셔너리
Raises:
ValueError: JSON 파싱 실패
"""
decrypted_string = self.decrypt(encrypted_data)
try:
return json.loads(decrypted_string)
except json.JSONDecodeError as e:
logger.error(f"JSON 파싱 실패: {e}")
raise ValueError("유효하지 않은 JSON 데이터입니다")
@staticmethod
def generate_new_key() -> str:
"""
암호화 생성
환경변수 설정에 사용할 있는 Fernet 키를 생성합니다.
Returns:
새로 생성된 Base64 인코딩 문자열
Example:
>>> new_key = EncryptionService.generate_new_key()
>>> print(f"INSTAGRAM_ENCRYPTION_KEY={new_key}")
"""
return Fernet.generate_key().decode()
# ============================================
# 테스트용 메인 함수
# ============================================
if __name__ == '__main__':
# 새 키 생성 테스트
print("=" * 50)
print("새 암호화 키 생성 테스트")
print("=" * 50)
new_key = EncryptionService.generate_new_key()
print(f"\n생성된 키: {new_key}")
print(f"\n환경변수 설정:")
print(f"INSTAGRAM_ENCRYPTION_KEY={new_key}")
# 암호화/복호화 테스트
print("\n" + "=" * 50)
print("암호화/복호화 테스트")
print("=" * 50)
encryptor = EncryptionService(new_key)
# 문자열 테스트
test_password = "my_instagram_password_123!"
encrypted = encryptor.encrypt(test_password)
decrypted = encryptor.decrypt(encrypted)
print(f"\n원본: {test_password}")
print(f"암호화: {encrypted[:50]}...")
print(f"복호화: {decrypted}")
print(f"일치: {test_password == decrypted}")
# JSON 테스트
print("\n" + "=" * 50)
print("JSON 암호화 테스트")
print("=" * 50)
test_session = {
'session_id': 'abc123',
'cookies': {'ds_user_id': '12345'},
'user_agent': 'Instagram 123.0'
}
encrypted_json = encryptor.encrypt_json(test_session)
decrypted_json = encryptor.decrypt_json(encrypted_json)
print(f"\n원본: {test_session}")
print(f"암호화: {encrypted_json[:50]}...")
print(f"복호화: {decrypted_json}")
print(f"일치: {test_session == decrypted_json}")

View File

@ -0,0 +1,558 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Instagram 클라이언트 매니저
instagrapi 라이브러리를 래핑하여 Instagram 로그인, 세션 관리,
영상 업로드 기능을 제공합니다.
주요 클래스:
- InstagramClientManager: Instagram API 작업을 위한 고수준 인터페이스
안전한 사용을 위한 권장사항:
- 1 이하 업로드 권장
- 빠른 연속 요청 자제
- 2FA 활성화된 계정 지원
"""
import os
import json
import time
import logging
from pathlib import Path
from typing import Optional, Dict, Any, Tuple
from datetime import datetime
from instagrapi import Client
from instagrapi.exceptions import (
LoginRequired,
TwoFactorRequired,
ChallengeRequired,
BadPassword,
PleaseWaitFewMinutes,
ClientError
)
from encryption_service import EncryptionService
logger = logging.getLogger(__name__)
# ============================================
# 상수 정의
# ============================================
# 기본 User-Agent (Instagram Android 앱)
DEFAULT_USER_AGENT = (
"Instagram 275.0.0.27.98 Android "
"(33/13; 420dpi; 1080x2400; samsung; SM-G998B; "
"o1s; exynos2100; ko_KR; 458229258)"
)
# 업로드 재시도 설정
MAX_UPLOAD_RETRIES = 2
RETRY_DELAY_SECONDS = 5
# 세션 파일 저장 경로 (디버깅용)
SESSION_DIR = Path(__file__).parent / 'sessions'
class InstagramClientManager:
"""
Instagram API 클라이언트 매니저
Instagram 계정 로그인, 세션 관리, 영상 업로드 등의 기능을 제공합니다.
모든 민감한 데이터는 암호화하여 처리합니다.
Attributes:
encryption: 암호화 서비스 인스턴스
Example:
>>> manager = InstagramClientManager(encryption_service)
>>> result = manager.login("username", "password")
>>> if result['success']:
... print(f"로그인 성공! 세션: {result['encrypted_session']}")
"""
def __init__(self, encryption_service: EncryptionService):
"""
Instagram 클라이언트 매니저 초기화
Args:
encryption_service: 비밀번호/세션 암호화를 위한 서비스
"""
self.encryption = encryption_service
# 세션 디렉토리 생성 (디버깅용)
SESSION_DIR.mkdir(exist_ok=True)
def _create_client(self) -> Client:
"""
Instagram 클라이언트 인스턴스 생성
적절한 설정으로 초기화된 instagrapi Client를 반환합니다.
Returns:
초기화된 Client 인스턴스
"""
client = Client()
# 기본 설정
client.delay_range = [1, 3] # API 호출 간 딜레이 (초)
client.set_locale('ko_KR')
client.set_timezone_offset(9 * 3600) # KST (UTC+9)
return client
def _handle_login_error(self, error: Exception, username: str) -> Dict[str, Any]:
"""
로그인 에러 처리 적절한 응답 생성
Args:
error: 발생한 예외
username: 로그인 시도한 사용자명
Returns:
에러 정보가 담긴 딕셔너리
"""
error_str = str(error).lower()
if isinstance(error, BadPassword):
return {
'success': False,
'error': '아이디 또는 비밀번호가 올바르지 않습니다.',
'error_code': 'BAD_PASSWORD'
}
elif isinstance(error, TwoFactorRequired):
return {
'success': False,
'error': '2단계 인증이 필요합니다. 인증 코드를 입력해주세요.',
'error_code': 'TWO_FACTOR_REQUIRED',
'requires_2fa': True
}
elif isinstance(error, ChallengeRequired):
return {
'success': False,
'error': 'Instagram 보안 확인이 필요합니다. 앱에서 확인 후 다시 시도해주세요.',
'error_code': 'CHALLENGE_REQUIRED'
}
elif isinstance(error, PleaseWaitFewMinutes):
return {
'success': False,
'error': '너무 많은 요청이 발생했습니다. 몇 분 후 다시 시도해주세요.',
'error_code': 'RATE_LIMITED'
}
elif 'checkpoint' in error_str or 'challenge' in error_str:
return {
'success': False,
'error': 'Instagram에서 계정 확인을 요청했습니다. 앱에서 확인 후 다시 시도해주세요.',
'error_code': 'CHECKPOINT_REQUIRED'
}
else:
logger.error(f"[로그인 에러] 사용자: {username}, 에러: {error}", exc_info=True)
return {
'success': False,
'error': f'로그인 중 오류가 발생했습니다: {str(error)}',
'error_code': 'LOGIN_FAILED'
}
def login(
self,
username: str,
password: str,
verification_code: Optional[str] = None
) -> Dict[str, Any]:
"""
Instagram 계정 로그인
사용자명과 비밀번호로 Instagram에 로그인합니다.
성공 암호화된 비밀번호와 세션 정보를 반환합니다.
Args:
username: Instagram 사용자명
password: Instagram 비밀번호
verification_code: 2FA 인증 코드 (선택)
Returns:
성공 : {
'success': True,
'encrypted_password': '암호화된 비밀번호',
'encrypted_session': '암호화된 세션',
'username': '확인된 사용자명',
'user_id': 'Instagram 사용자 ID',
'full_name': '표시 이름'
}
실패 : {
'success': False,
'error': '에러 메시지',
'error_code': '에러 코드'
}
"""
client = self._create_client()
try:
logger.info(f"[로그인 시도] 사용자: {username}")
# 2FA 코드가 있는 경우
if verification_code:
logger.info(f"[2FA 인증] 코드 입력됨")
client.login(
username,
password,
verification_code=verification_code
)
else:
client.login(username, password)
# 로그인 성공 - 세션 정보 추출
session_data = client.get_settings()
user_id = client.user_id
user_info = client.user_info(user_id)
# 비밀번호와 세션 암호화
encrypted_password = self.encryption.encrypt(password)
encrypted_session = self.encryption.encrypt_json(session_data)
logger.info(f"[로그인 성공] 사용자: {username}, ID: {user_id}")
return {
'success': True,
'encrypted_password': encrypted_password,
'encrypted_session': encrypted_session,
'username': user_info.username,
'user_id': str(user_id),
'full_name': user_info.full_name,
'profile_pic_url': str(user_info.profile_pic_url) if user_info.profile_pic_url else None
}
except TwoFactorRequired:
# 2FA 필요 - 사용자에게 코드 입력 요청
logger.info(f"[2FA 필요] 사용자: {username}")
return {
'success': False,
'error': '2단계 인증이 필요합니다. 인증 코드를 입력해주세요.',
'error_code': 'TWO_FACTOR_REQUIRED',
'requires_2fa': True
}
except Exception as e:
return self._handle_login_error(e, username)
def login_with_session(self, encrypted_session: str) -> Tuple[Optional[Client], Optional[str]]:
"""
저장된 세션으로 로그인
암호화된 세션 데이터를 복호화하여 로그인 상태를 복원합니다.
로그인 과정 없이 빠르게 인증된 클라이언트를 얻을 있습니다.
Args:
encrypted_session: 암호화된 세션 JSON 문자열
Returns:
(Client 인스턴스, None) - 성공
(None, 에러 메시지) - 실패
"""
try:
# 세션 복호화
session_data = self.encryption.decrypt_json(encrypted_session)
# 클라이언트 생성 및 세션 설정
client = self._create_client()
client.set_settings(session_data)
# 세션 유효성 확인 (간단한 API 호출)
client.get_timeline_feed()
return client, None
except Exception as e:
logger.warning(f"[세션 로그인 실패] {e}")
return None, str(e)
def verify_and_refresh_session(
self,
encrypted_session: str,
encrypted_password: Optional[str] = None,
username: Optional[str] = None
) -> Dict[str, Any]:
"""
세션 유효성 검증 필요시 갱신
저장된 세션이 유효한지 확인하고, 만료된 경우
비밀번호로 재로그인을 시도합니다.
Args:
encrypted_session: 암호화된 세션
encrypted_password: 암호화된 비밀번호 (재로그인용)
username: 사용자명 (재로그인용)
Returns:
세션 상태 갱신된 세션 정보
"""
# 먼저 기존 세션으로 시도
client, error = self.login_with_session(encrypted_session)
if client:
return {
'success': True,
'valid': True,
'message': '세션이 유효합니다.',
'encrypted_session': encrypted_session
}
# 세션 만료 - 재로그인 시도
if encrypted_password and username:
logger.info(f"[세션 만료] 재로그인 시도: {username}")
try:
password = self.encryption.decrypt(encrypted_password)
return self.login(username, password)
except Exception as e:
logger.error(f"[재로그인 실패] {e}")
return {
'success': False,
'valid': False,
'error': '세션이 만료되었고 재로그인에 실패했습니다.',
'error_code': 'SESSION_EXPIRED'
}
else:
return {
'success': False,
'valid': False,
'error': '세션이 만료되었습니다. 다시 로그인해주세요.',
'error_code': 'SESSION_EXPIRED'
}
def upload_video(
self,
encrypted_session: str,
encrypted_password: Optional[str],
username: Optional[str],
video_path: str,
caption: str = "",
thumbnail_path: Optional[str] = None,
as_reel: bool = True
) -> Dict[str, Any]:
"""
Instagram에 영상 업로드
릴스(Reels) 또는 일반 비디오로 영상을 업로드합니다.
Args:
encrypted_session: 암호화된 세션
encrypted_password: 암호화된 비밀번호 (세션 만료 재로그인용)
username: 사용자명
video_path: 업로드할 영상 파일 경로
caption: 게시물 캡션 (해시태그 포함 가능)
thumbnail_path: 커버 이미지 경로 (선택)
as_reel: True면 릴스로, False면 일반 비디오로 업로드
Returns:
성공 : {
'success': True,
'media_id': 'Instagram 미디어 ID',
'post_code': '게시물 코드',
'permalink': '게시물 URL',
'new_session': '갱신된 세션 (있는 경우)'
}
실패 : {
'success': False,
'error': '에러 메시지',
'error_code': '에러 코드'
}
"""
client = None
new_session = None
# 1. 세션으로 로그인 시도
client, error = self.login_with_session(encrypted_session)
# 2. 세션 만료 시 재로그인
if not client and encrypted_password and username:
logger.info(f"[세션 만료] 재로그인 후 업로드 시도: {username}")
try:
password = self.encryption.decrypt(encrypted_password)
login_result = self.login(username, password)
if login_result['success']:
client, _ = self.login_with_session(login_result['encrypted_session'])
new_session = login_result['encrypted_session']
except Exception as e:
logger.error(f"[재로그인 실패] {e}")
if not client:
return {
'success': False,
'error': '로그인에 실패했습니다. 계정 연결을 다시 해주세요.',
'error_code': 'LOGIN_FAILED'
}
# 3. 업로드 실행 (재시도 로직 포함)
for attempt in range(MAX_UPLOAD_RETRIES + 1):
try:
logger.info(f"[업로드 시도 {attempt + 1}/{MAX_UPLOAD_RETRIES + 1}] {video_path}")
# 파일 경로 처리
video_path_obj = Path(video_path)
if as_reel:
# 릴스로 업로드
if thumbnail_path and Path(thumbnail_path).exists():
media = client.clip_upload(
path=video_path_obj,
caption=caption,
thumbnail=Path(thumbnail_path)
)
else:
media = client.clip_upload(
path=video_path_obj,
caption=caption
)
else:
# 일반 비디오로 업로드
if thumbnail_path and Path(thumbnail_path).exists():
media = client.video_upload(
path=video_path_obj,
caption=caption,
thumbnail=Path(thumbnail_path)
)
else:
media = client.video_upload(
path=video_path_obj,
caption=caption
)
# 업로드 성공
result = {
'success': True,
'media_id': str(media.pk),
'post_code': media.code,
'permalink': f'https://www.instagram.com/p/{media.code}/',
'media_type': 'reel' if as_reel else 'video'
}
if new_session:
result['new_session'] = new_session
logger.info(f"[업로드 성공] 미디어 ID: {media.pk}")
return result
except PleaseWaitFewMinutes as e:
logger.warning(f"[Rate Limit] {e}")
if attempt < MAX_UPLOAD_RETRIES:
time.sleep(RETRY_DELAY_SECONDS * (attempt + 1))
continue
return {
'success': False,
'error': '업로드 제한에 걸렸습니다. 나중에 다시 시도해주세요.',
'error_code': 'RATE_LIMITED'
}
except LoginRequired as e:
logger.error(f"[로그인 필요] {e}")
return {
'success': False,
'error': '세션이 만료되었습니다. 다시 로그인해주세요.',
'error_code': 'SESSION_EXPIRED'
}
except Exception as e:
logger.error(f"[업로드 에러] {e}", exc_info=True)
if attempt < MAX_UPLOAD_RETRIES:
time.sleep(RETRY_DELAY_SECONDS)
continue
return {
'success': False,
'error': f'업로드 중 오류가 발생했습니다: {str(e)}',
'error_code': 'UPLOAD_FAILED'
}
return {
'success': False,
'error': '업로드에 실패했습니다. 최대 재시도 횟수를 초과했습니다.',
'error_code': 'MAX_RETRIES_EXCEEDED'
}
def get_account_info(self, encrypted_session: str) -> Dict[str, Any]:
"""
연결된 Instagram 계정 정보 조회
Args:
encrypted_session: 암호화된 세션
Returns:
계정 정보 딕셔너리
"""
client, error = self.login_with_session(encrypted_session)
if not client:
return {
'success': False,
'error': '세션이 유효하지 않습니다.',
'error_code': 'INVALID_SESSION'
}
try:
user_id = client.user_id
user_info = client.user_info(user_id)
return {
'success': True,
'username': user_info.username,
'full_name': user_info.full_name,
'profile_pic_url': str(user_info.profile_pic_url) if user_info.profile_pic_url else None,
'follower_count': user_info.follower_count,
'following_count': user_info.following_count,
'media_count': user_info.media_count,
'is_private': user_info.is_private,
'is_verified': user_info.is_verified
}
except Exception as e:
logger.error(f"[계정 정보 조회 실패] {e}")
return {
'success': False,
'error': f'계정 정보를 가져오는 데 실패했습니다: {str(e)}',
'error_code': 'INFO_FAILED'
}
def logout(self, encrypted_session: str) -> bool:
"""
로그아웃 (세션 무효화)
실제로 Instagram 세션을 로그아웃하지는 않고,
로컬에서 세션 데이터만 제거합니다.
Args:
encrypted_session: 암호화된 세션
Returns:
항상 True (에러 무시)
"""
try:
client, _ = self.login_with_session(encrypted_session)
if client:
# Instagram API에는 공식 로그아웃이 없음
# 세션 데이터만 버림
pass
except:
pass
return True
# ============================================
# 테스트용 메인 함수
# ============================================
if __name__ == '__main__':
from encryption_service import EncryptionService
print("=" * 50)
print("Instagram 클라이언트 매니저 테스트")
print("=" * 50)
print("\n이 모듈은 직접 실행하지 않습니다.")
print("instagram_service.py를 실행하세요.")
print("\n사용 예:")
print(" python instagram_service.py")

View File

@ -0,0 +1,432 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Instagram 자동 업로드 서비스
모듈은 Instagram 계정 연결 릴스/영상 업로드 기능을 제공합니다.
Flask REST API를 통해 Node.js 백엔드와 통신합니다.
주요 기능:
1. Instagram 계정 로그인 세션 관리
2. 비밀번호/세션 암호화 저장
3. 릴스(Reels) 영상 업로드
4. 업로드 상태 추적
사용법:
python instagram_service.py
환경변수:
INSTAGRAM_ENCRYPTION_KEY: Fernet 암호화 (32바이트 base64)
INSTAGRAM_SERVICE_PORT: 서비스 포트 (기본: 5001)
"""
import os
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, Any, Tuple
from flask import Flask, request, jsonify
from flask_cors import CORS
from dotenv import load_dotenv
from encryption_service import EncryptionService
from instagram_client import InstagramClientManager
# ============================================
# 로깅 설정
# ============================================
logging.basicConfig(
level=logging.INFO,
format='[%(asctime)s] [%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
# ============================================
# 환경변수 로드
# ============================================
load_dotenv(dotenv_path=Path(__file__).parent.parent.parent / '.env')
# Flask 앱 초기화
app = Flask(__name__)
CORS(app)
# 서비스 초기화
encryption_service: Optional[EncryptionService] = None
instagram_manager: Optional[InstagramClientManager] = None
def get_encryption_service() -> EncryptionService:
"""암호화 서비스 인스턴스 반환 (지연 초기화)"""
global encryption_service
if encryption_service is None:
key = os.getenv('INSTAGRAM_ENCRYPTION_KEY')
if not key:
# 키가 없으면 새로 생성하고 경고
logger.warning("INSTAGRAM_ENCRYPTION_KEY 환경변수가 없습니다. 새 키를 생성합니다.")
encryption_service = EncryptionService()
logger.warning(f"생성된 키 (환경변수에 저장하세요): {encryption_service.get_key_string()}")
else:
encryption_service = EncryptionService(key)
return encryption_service
def get_instagram_manager() -> InstagramClientManager:
"""Instagram 클라이언트 매니저 인스턴스 반환 (지연 초기화)"""
global instagram_manager
if instagram_manager is None:
instagram_manager = InstagramClientManager(get_encryption_service())
return instagram_manager
# ============================================
# API 엔드포인트
# ============================================
@app.route('/health', methods=['GET'])
def health_check():
"""
헬스 체크 엔드포인트
서비스가 정상 동작 중인지 확인합니다.
"""
return jsonify({
'status': 'ok',
'service': 'instagram-upload-service',
'timestamp': datetime.now().isoformat()
})
@app.route('/connect', methods=['POST'])
def connect_account():
"""
Instagram 계정 연결
사용자의 Instagram 아이디/비밀번호로 로그인을 시도하고,
성공 암호화된 비밀번호와 세션 정보를 반환합니다.
Request Body:
{
"username": "instagram_username",
"password": "instagram_password",
"verification_code": "2FA 코드 (선택사항)"
}
Response:
성공: {
"success": true,
"encrypted_password": "암호화된 비밀번호",
"encrypted_session": "암호화된 세션 데이터",
"username": "확인된 사용자명",
"user_id": "Instagram 사용자 ID"
}
실패: {
"success": false,
"error": "에러 메시지",
"error_code": "에러 코드",
"requires_2fa": true/false
}
"""
try:
data = request.get_json()
if not data:
return jsonify({
'success': False,
'error': '요청 데이터가 없습니다.',
'error_code': 'NO_DATA'
}), 400
# None 값 안전하게 처리
raw_username = data.get('username')
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:
return jsonify({
'success': False,
'error': '아이디와 비밀번호를 입력해주세요.',
'error_code': 'MISSING_CREDENTIALS'
}), 400
logger.info(f"[Instagram 연결 시도] 사용자: {username}")
manager = get_instagram_manager()
result = manager.login(username, password, verification_code)
if result['success']:
logger.info(f"[Instagram 연결 성공] 사용자: {username}")
return jsonify(result)
else:
logger.warning(f"[Instagram 연결 실패] 사용자: {username}, 에러: {result.get('error')}")
return jsonify(result), 401 if result.get('error_code') == 'LOGIN_FAILED' else 400
except Exception as e:
logger.error(f"[Instagram 연결 에러] {str(e)}", exc_info=True)
return jsonify({
'success': False,
'error': f'서버 오류가 발생했습니다: {str(e)}',
'error_code': 'SERVER_ERROR'
}), 500
@app.route('/verify-session', methods=['POST'])
def verify_session():
"""
저장된 세션 유효성 검증
암호화된 세션으로 로그인이 가능한지 확인합니다.
세션이 만료되었으면 비밀번호로 재로그인을 시도합니다.
Request Body:
{
"encrypted_session": "암호화된 세션",
"encrypted_password": "암호화된 비밀번호 (재로그인용)",
"username": "사용자명"
}
Response:
성공: {
"success": true,
"valid": true,
"new_session": "새 세션 (갱신된 경우)",
"username": "사용자명"
}
"""
try:
data = request.get_json()
encrypted_session = data.get('encrypted_session')
encrypted_password = data.get('encrypted_password')
username = data.get('username')
if not encrypted_session:
return jsonify({
'success': False,
'error': '세션 정보가 없습니다.',
'error_code': 'NO_SESSION'
}), 400
manager = get_instagram_manager()
result = manager.verify_and_refresh_session(
encrypted_session,
encrypted_password,
username
)
return jsonify(result)
except Exception as e:
logger.error(f"[세션 검증 에러] {str(e)}", exc_info=True)
return jsonify({
'success': False,
'error': f'세션 검증 중 오류: {str(e)}',
'error_code': 'SERVER_ERROR'
}), 500
@app.route('/upload', methods=['POST'])
def upload_video():
"""
Instagram에 릴스/영상 업로드
저장된 세션을 사용하여 영상을 Instagram에 업로드합니다.
Request Body:
{
"encrypted_session": "암호화된 세션",
"encrypted_password": "암호화된 비밀번호 (세션 만료 시 재로그인용)",
"username": "사용자명",
"video_path": "업로드할 영상 파일 경로",
"caption": "게시물 캡션",
"thumbnail_path": "썸네일 이미지 경로 (선택)",
"upload_as_reel": true/false (기본: true)
}
Response:
성공: {
"success": true,
"media_id": "Instagram 미디어 ID",
"post_code": "게시물 코드",
"permalink": "게시물 URL"
}
"""
try:
data = request.get_json()
encrypted_session = data.get('encrypted_session')
encrypted_password = data.get('encrypted_password')
username = data.get('username')
video_path = data.get('video_path')
caption = data.get('caption', '')
thumbnail_path = data.get('thumbnail_path')
upload_as_reel = data.get('upload_as_reel', True)
# 필수 필드 검증
if not encrypted_session:
return jsonify({
'success': False,
'error': '세션 정보가 없습니다. 먼저 계정을 연결해주세요.',
'error_code': 'NO_SESSION'
}), 400
if not video_path:
return jsonify({
'success': False,
'error': '업로드할 영상 경로가 없습니다.',
'error_code': 'NO_VIDEO'
}), 400
# 파일 존재 확인
if not os.path.exists(video_path):
return jsonify({
'success': False,
'error': f'영상 파일을 찾을 수 없습니다: {video_path}',
'error_code': 'FILE_NOT_FOUND'
}), 400
logger.info(f"[Instagram 업로드 시작] 사용자: {username}, 파일: {video_path}")
manager = get_instagram_manager()
result = manager.upload_video(
encrypted_session=encrypted_session,
encrypted_password=encrypted_password,
username=username,
video_path=video_path,
caption=caption,
thumbnail_path=thumbnail_path,
as_reel=upload_as_reel
)
if result['success']:
logger.info(f"[Instagram 업로드 성공] 미디어 ID: {result.get('media_id')}")
return jsonify(result)
else:
logger.error(f"[Instagram 업로드 실패] {result.get('error')}")
return jsonify(result), 400
except Exception as e:
logger.error(f"[Instagram 업로드 에러] {str(e)}", exc_info=True)
return jsonify({
'success': False,
'error': f'업로드 중 오류 발생: {str(e)}',
'error_code': 'UPLOAD_ERROR'
}), 500
@app.route('/disconnect', methods=['POST'])
def disconnect_account():
"""
Instagram 계정 연결 해제
로컬에 저장된 세션 정보만 삭제합니다.
Instagram 계정 자체에는 영향이 없습니다.
Request Body:
{
"encrypted_session": "암호화된 세션"
}
Response:
{
"success": true,
"message": "계정 연결이 해제되었습니다."
}
"""
try:
data = request.get_json()
encrypted_session = data.get('encrypted_session')
if encrypted_session:
manager = get_instagram_manager()
manager.logout(encrypted_session)
return jsonify({
'success': True,
'message': '계정 연결이 해제되었습니다.'
})
except Exception as e:
logger.error(f"[연결 해제 에러] {str(e)}", exc_info=True)
return jsonify({
'success': True, # 에러가 나도 성공으로 처리 (어차피 연결 해제)
'message': '계정 연결이 해제되었습니다.'
})
@app.route('/account-info', methods=['POST'])
def get_account_info():
"""
Instagram 계정 정보 조회
연결된 계정의 기본 정보를 조회합니다.
Request Body:
{
"encrypted_session": "암호화된 세션"
}
Response:
{
"success": true,
"username": "사용자명",
"full_name": "이름",
"profile_pic_url": "프로필 사진 URL",
"follower_count": 팔로워 ,
"media_count": 게시물
}
"""
try:
data = request.get_json()
encrypted_session = data.get('encrypted_session')
if not encrypted_session:
return jsonify({
'success': False,
'error': '세션 정보가 없습니다.',
'error_code': 'NO_SESSION'
}), 400
manager = get_instagram_manager()
result = manager.get_account_info(encrypted_session)
return jsonify(result)
except Exception as e:
logger.error(f"[계정 정보 조회 에러] {str(e)}", exc_info=True)
return jsonify({
'success': False,
'error': f'계정 정보 조회 중 오류: {str(e)}',
'error_code': 'SERVER_ERROR'
}), 500
# ============================================
# 메인 실행
# ============================================
if __name__ == '__main__':
port = int(os.getenv('INSTAGRAM_SERVICE_PORT', 5001))
debug = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
logger.info(f"Instagram 업로드 서비스 시작 - 포트: {port}")
logger.info("엔드포인트:")
logger.info(" GET /health - 헬스 체크")
logger.info(" POST /connect - 계정 연결")
logger.info(" POST /verify-session - 세션 검증")
logger.info(" POST /upload - 영상 업로드")
logger.info(" POST /disconnect - 계정 연결 해제")
logger.info(" POST /account-info - 계정 정보 조회")
app.run(
host='0.0.0.0',
port=port,
debug=debug
)

View File

@ -0,0 +1,9 @@
# Instagram 자동 업로드를 위한 Python 패키지
# pip install -r requirements.txt
instagrapi==2.1.2 # Instagram Private API 클라이언트
Pillow>=8.1.1 # 이미지 처리 (instagrapi 의존성)
cryptography==41.0.7 # 비밀번호/세션 암호화
flask==3.0.0 # REST API 서버
flask-cors==4.0.0 # CORS 지원
python-dotenv==1.0.0 # 환경변수 로드

View File

@ -0,0 +1,611 @@
/**
* Instagram 업로드 서비스
*
* Python Instagram 마이크로서비스와 통신하여
* Instagram 계정 연결 영상 업로드 기능을 제공합니다.
*
* 주요 기능:
* 1. Instagram 계정 연결 (로그인)
* 2. 계정 연결 해제
* 3. 릴스/영상 업로드
* 4. 업로드 히스토리 관리
*
* @module instagramService
*/
const db = require('./db');
const path = require('path');
// ============================================
// 설정
// ============================================
// Python Instagram 서비스 URL
const INSTAGRAM_SERVICE_URL = process.env.INSTAGRAM_SERVICE_URL || 'http://localhost:5001';
// 주당 최대 업로드 횟수 (안전한 사용을 위해)
const MAX_UPLOADS_PER_WEEK = 1;
// ============================================
// 유틸리티 함수
// ============================================
/**
* Python Instagram 서비스에 HTTP 요청
*
* @param {string} endpoint - API 엔드포인트 (: '/connect')
* @param {Object} data - 요청 본문 데이터
* @returns {Promise<Object>} 응답 데이터
*/
async function callInstagramService(endpoint, data = {}) {
const url = `${INSTAGRAM_SERVICE_URL}${endpoint}`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
return result;
} catch (error) {
console.error(`[Instagram 서비스 호출 실패] ${endpoint}:`, error.message);
// 서비스 연결 실패
if (error.code === 'ECONNREFUSED') {
throw new Error('Instagram 서비스에 연결할 수 없습니다. 서비스가 실행 중인지 확인하세요.');
}
throw error;
}
}
/**
* Instagram 서비스 헬스 체크
*
* @returns {Promise<boolean>} 서비스 정상 여부
*/
async function checkServiceHealth() {
try {
const response = await fetch(`${INSTAGRAM_SERVICE_URL}/health`);
const data = await response.json();
return data.status === 'ok';
} catch (error) {
console.error('[Instagram 서비스 헬스 체크 실패]', error.message);
return false;
}
}
// ============================================
// 계정 연결 관리
// ============================================
/**
* Instagram 계정 연결
*
* 사용자의 Instagram 계정을 연결하고 인증 정보를 암호화하여 저장합니다.
*
* @param {number} userId - 사용자 ID
* @param {string} username - Instagram 사용자명
* @param {string} password - Instagram 비밀번호
* @param {string} [verificationCode] - 2FA 인증 코드 (선택)
* @returns {Promise<Object>} 연결 결과
*/
async function connectAccount(userId, username, password, verificationCode = null) {
console.log(`[Instagram 연결 시도] 사용자 ID: ${userId}, Instagram: ${username}`);
// Python 서비스에 로그인 요청
const loginResult = await callInstagramService('/connect', {
username,
password,
verification_code: verificationCode
});
if (!loginResult.success) {
return loginResult;
}
// DB에 연결 정보 저장
return new Promise((resolve, reject) => {
db.run(`
INSERT OR REPLACE INTO instagram_connections
(user_id, instagram_username, encrypted_password, encrypted_session,
is_active, last_login_at, connected_at, updated_at)
VALUES (?, ?, ?, ?, 1, datetime('now'), datetime('now'), datetime('now'))
`, [
userId,
loginResult.username,
loginResult.encrypted_password,
loginResult.encrypted_session
], function(err) {
if (err) {
console.error('[Instagram 연결 정보 저장 실패]', err);
reject(err);
return;
}
// 기본 설정도 생성
db.run(`
INSERT OR IGNORE INTO instagram_settings
(user_id, auto_upload, upload_as_reel, max_uploads_per_week)
VALUES (?, 0, 1, ?)
`, [userId, MAX_UPLOADS_PER_WEEK]);
console.log(`[Instagram 연결 성공] 사용자 ID: ${userId}`);
resolve({
success: true,
message: 'Instagram 계정이 연결되었습니다.',
username: loginResult.username,
user_id: loginResult.user_id,
full_name: loginResult.full_name,
profile_pic_url: loginResult.profile_pic_url
});
});
});
}
/**
* Instagram 계정 연결 해제
*
* @param {number} userId - 사용자 ID
* @returns {Promise<Object>} 해제 결과
*/
async function disconnectAccount(userId) {
console.log(`[Instagram 연결 해제] 사용자 ID: ${userId}`);
return new Promise((resolve, reject) => {
// 먼저 저장된 세션 조회
db.get(
'SELECT encrypted_session FROM instagram_connections WHERE user_id = ?',
[userId],
async (err, row) => {
if (err) {
reject(err);
return;
}
// Python 서비스에 로그아웃 요청 (실패해도 무시)
if (row && row.encrypted_session) {
try {
await callInstagramService('/disconnect', {
encrypted_session: row.encrypted_session
});
} catch (e) {
// 무시
}
}
// DB에서 삭제
db.run(
'DELETE FROM instagram_connections WHERE user_id = ?',
[userId],
function(err) {
if (err) {
reject(err);
return;
}
// 설정도 삭제
db.run(
'DELETE FROM instagram_settings WHERE user_id = ?',
[userId]
);
resolve({
success: true,
message: 'Instagram 계정 연결이 해제되었습니다.'
});
}
);
}
);
});
}
/**
* Instagram 연결 상태 조회
*
* @param {number} userId - 사용자 ID
* @returns {Promise<Object>} 연결 상태 정보
*/
async function getConnectionStatus(userId) {
return new Promise((resolve, reject) => {
db.get(`
SELECT
ic.instagram_username,
ic.is_active,
ic.two_factor_required,
ic.connected_at,
ic.last_login_at,
ic.encrypted_session,
inst.auto_upload,
inst.upload_as_reel,
inst.default_caption_template,
inst.default_hashtags,
inst.max_uploads_per_week,
inst.notify_on_upload
FROM instagram_connections ic
LEFT JOIN instagram_settings inst ON ic.user_id = inst.user_id
WHERE ic.user_id = ?
`, [userId], async (err, row) => {
if (err) {
reject(err);
return;
}
if (!row) {
resolve({
connected: false,
message: 'Instagram 계정이 연결되지 않았습니다.'
});
return;
}
// 계정 정보 조회 시도 (세션 유효성 확인)
let accountInfo = null;
if (row.encrypted_session) {
try {
const info = await callInstagramService('/account-info', {
encrypted_session: row.encrypted_session
});
if (info.success) {
accountInfo = info;
}
} catch (e) {
// 세션 만료 가능성
console.warn('[Instagram 계정 정보 조회 실패]', e.message);
}
}
resolve({
connected: true,
username: row.instagram_username,
is_active: row.is_active === 1,
two_factor_required: row.two_factor_required === 1,
connected_at: row.connected_at,
last_login_at: row.last_login_at,
settings: {
auto_upload: row.auto_upload === 1,
upload_as_reel: row.upload_as_reel === 1,
default_caption_template: row.default_caption_template,
default_hashtags: row.default_hashtags,
max_uploads_per_week: row.max_uploads_per_week || MAX_UPLOADS_PER_WEEK,
notify_on_upload: row.notify_on_upload === 1
},
account_info: accountInfo ? {
full_name: accountInfo.full_name,
profile_pic_url: accountInfo.profile_pic_url,
follower_count: accountInfo.follower_count,
media_count: accountInfo.media_count
} : null
});
});
});
}
// ============================================
// 설정 관리
// ============================================
/**
* Instagram 업로드 설정 업데이트
*
* @param {number} userId - 사용자 ID
* @param {Object} settings - 설정 객체
* @returns {Promise<Object>} 업데이트 결과
*/
async function updateSettings(userId, settings) {
return new Promise((resolve, reject) => {
const {
auto_upload,
upload_as_reel,
default_caption_template,
default_hashtags,
max_uploads_per_week,
notify_on_upload
} = settings;
db.run(`
INSERT OR REPLACE INTO instagram_settings
(user_id, auto_upload, upload_as_reel, default_caption_template,
default_hashtags, max_uploads_per_week, notify_on_upload, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, [
userId,
auto_upload ? 1 : 0,
upload_as_reel !== false ? 1 : 0, // 기본값 true
default_caption_template || null,
default_hashtags || null,
max_uploads_per_week || MAX_UPLOADS_PER_WEEK,
notify_on_upload !== false ? 1 : 0 // 기본값 true
], function(err) {
if (err) {
reject(err);
return;
}
resolve({
success: true,
message: '설정이 저장되었습니다.'
});
});
});
}
// ============================================
// 영상 업로드
// ============================================
/**
* 주간 업로드 횟수 확인
*
* @param {number} userId - 사용자 ID
* @returns {Promise<Object>} 이번 업로드 정보
*/
async function getWeeklyUploadCount(userId) {
return new Promise((resolve, reject) => {
// 이번 주 월요일 기준
db.get(`
SELECT
COUNT(*) as count,
MAX(uploaded_at) as last_upload
FROM instagram_upload_history
WHERE user_id = ?
AND status = 'success'
AND uploaded_at >= date('now', 'weekday 0', '-6 days')
`, [userId], (err, row) => {
if (err) {
reject(err);
return;
}
db.get(
'SELECT max_uploads_per_week FROM instagram_settings WHERE user_id = ?',
[userId],
(err, settings) => {
resolve({
count: row?.count || 0,
max: settings?.max_uploads_per_week || MAX_UPLOADS_PER_WEEK,
last_upload: row?.last_upload
});
}
);
});
});
}
/**
* Instagram에 영상 업로드
*
* @param {number} userId - 사용자 ID
* @param {number} historyId - 영상 히스토리 ID
* @param {string} videoPath - 영상 파일 경로
* @param {string} caption - 게시물 캡션
* @param {Object} [options] - 추가 옵션
* @param {string} [options.thumbnailPath] - 썸네일 이미지 경로
* @param {boolean} [options.forceUpload] - 주간 제한 무시 여부
* @returns {Promise<Object>} 업로드 결과
*/
async function uploadVideo(userId, historyId, videoPath, caption, options = {}) {
console.log(`[Instagram 업로드 시작] 사용자 ID: ${userId}, 히스토리: ${historyId}`);
const { thumbnailPath, forceUpload = false } = options;
// 1. 주간 업로드 횟수 확인
if (!forceUpload) {
const weeklyStats = await getWeeklyUploadCount(userId);
if (weeklyStats.count >= weeklyStats.max) {
console.log(`[Instagram 업로드 제한] 주간 최대 ${weeklyStats.max}회 초과`);
return {
success: false,
error: `이번 주 업로드 제한(${weeklyStats.max}회)을 초과했습니다. 다음 주에 다시 시도해주세요.`,
error_code: 'WEEKLY_LIMIT_EXCEEDED',
weekly_count: weeklyStats.count,
weekly_max: weeklyStats.max,
last_upload: weeklyStats.last_upload
};
}
}
// 2. 연결 정보 조회
const connection = await new Promise((resolve, reject) => {
db.get(`
SELECT * FROM instagram_connections WHERE user_id = ? AND is_active = 1
`, [userId], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
if (!connection) {
return {
success: false,
error: 'Instagram 계정이 연결되지 않았습니다.',
error_code: 'NOT_CONNECTED'
};
}
// 3. 설정 조회
const settings = await new Promise((resolve, reject) => {
db.get(
'SELECT * FROM instagram_settings WHERE user_id = ?',
[userId],
(err, row) => {
if (err) reject(err);
else resolve(row || {});
}
);
});
// 4. 업로드 히스토리 레코드 생성 (pending 상태)
const uploadHistoryId = await new Promise((resolve, reject) => {
db.run(`
INSERT INTO instagram_upload_history
(user_id, history_id, caption, upload_type, status, createdAt)
VALUES (?, ?, ?, ?, 'pending', datetime('now'))
`, [
userId,
historyId,
caption,
settings.upload_as_reel ? 'reel' : 'video'
], function(err) {
if (err) reject(err);
else resolve(this.lastID);
});
});
// 5. Python 서비스에 업로드 요청
try {
const result = await callInstagramService('/upload', {
encrypted_session: connection.encrypted_session,
encrypted_password: connection.encrypted_password,
username: connection.instagram_username,
video_path: videoPath,
caption: caption,
thumbnail_path: thumbnailPath,
upload_as_reel: settings.upload_as_reel !== 0
});
if (result.success) {
// 성공 - 히스토리 업데이트
await new Promise((resolve, reject) => {
db.run(`
UPDATE instagram_upload_history
SET instagram_media_id = ?,
instagram_post_code = ?,
permalink = ?,
status = 'success',
uploaded_at = datetime('now')
WHERE id = ?
`, [
result.media_id,
result.post_code,
result.permalink,
uploadHistoryId
], (err) => {
if (err) reject(err);
else resolve();
});
});
// 세션 갱신된 경우 DB 업데이트
if (result.new_session) {
db.run(`
UPDATE instagram_connections
SET encrypted_session = ?, last_login_at = datetime('now'), updated_at = datetime('now')
WHERE user_id = ?
`, [result.new_session, userId]);
}
console.log(`[Instagram 업로드 성공] 미디어 ID: ${result.media_id}`);
return {
success: true,
media_id: result.media_id,
post_code: result.post_code,
permalink: result.permalink,
upload_history_id: uploadHistoryId
};
} else {
// 실패 - 히스토리 업데이트
await new Promise((resolve, reject) => {
db.run(`
UPDATE instagram_upload_history
SET status = 'failed',
error_message = ?,
retry_count = retry_count + 1
WHERE id = ?
`, [result.error, uploadHistoryId], (err) => {
if (err) reject(err);
else resolve();
});
});
console.error(`[Instagram 업로드 실패] ${result.error}`);
return result;
}
} catch (error) {
// 에러 - 히스토리 업데이트
await new Promise((resolve) => {
db.run(`
UPDATE instagram_upload_history
SET status = 'failed',
error_message = ?,
retry_count = retry_count + 1
WHERE id = ?
`, [error.message, uploadHistoryId], () => resolve());
});
console.error(`[Instagram 업로드 에러]`, error);
throw error;
}
}
// ============================================
// 히스토리 조회
// ============================================
/**
* Instagram 업로드 히스토리 조회
*
* @param {number} userId - 사용자 ID
* @param {number} [limit=20] - 최대 조회 개수
* @param {number} [offset=0] - 시작 위치
* @returns {Promise<Array>} 업로드 히스토리 목록
*/
async function getUploadHistory(userId, limit = 20, offset = 0) {
return new Promise((resolve, reject) => {
db.all(`
SELECT
iuh.*,
h.business_name,
h.details as video_details,
pp.brand_name as pension_name
FROM instagram_upload_history iuh
LEFT JOIN history h ON iuh.history_id = h.id
LEFT JOIN pension_profiles pp ON iuh.pension_id = pp.id
WHERE iuh.user_id = ?
ORDER BY iuh.createdAt DESC
LIMIT ? OFFSET ?
`, [userId, limit, offset], (err, rows) => {
if (err) {
reject(err);
return;
}
resolve(rows || []);
});
});
}
// ============================================
// 모듈 익스포트
// ============================================
module.exports = {
// 서비스 상태
checkServiceHealth,
// 계정 관리
connectAccount,
disconnectAccount,
getConnectionStatus,
// 설정
updateSettings,
// 업로드
uploadVideo,
getWeeklyUploadCount,
getUploadHistory,
// 상수
INSTAGRAM_SERVICE_URL,
MAX_UPLOADS_PER_WEEK
};

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);

5100
castad-data/server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
{
"name": "bizvibe-render-server",
"version": "0.5.0",
"description": "Puppeteer render server for BizVibe",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"@google-cloud/bigquery": "^8.1.1",
"@google-cloud/billing": "^5.1.1",
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"googleapis": "^166.0.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"node-cron": "^4.2.1",
"open": "^11.0.0",
"puppeteer": "^19.0.0",
"puppeteer-screen-recorder": "^3.0.0",
"resend": "^6.5.2",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"concurrently": "^9.2.1"
}
}

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();

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

@ -0,0 +1,580 @@
/**
* CaStAD Statistics Service v3.0.0
* 고급 통계 분석 서비스
*/
const db = require('./db');
/**
* 일별 시스템 통계 스냅샷 생성/업데이트
*/
async function updateDailyStats() {
const today = new Date().toISOString().split('T')[0];
return new Promise((resolve, reject) => {
// 오늘의 통계 계산
db.serialize(() => {
// 전체 사용자 수
db.get('SELECT COUNT(*) as total FROM users', [], (err, totalUsers) => {
if (err) return reject(err);
// 오늘 신규 가입자
db.get(`
SELECT COUNT(*) as count FROM users
WHERE date(createdAt) = date('now')
`, [], (err, newUsers) => {
if (err) return reject(err);
// 오늘 활성 사용자 (영상 생성)
db.get(`
SELECT COUNT(DISTINCT user_id) as count FROM history
WHERE date(createdAt) = date('now')
`, [], (err, activeUsers) => {
if (err) return reject(err);
// 오늘 생성된 영상 수
db.get(`
SELECT COUNT(*) as count FROM history
WHERE date(createdAt) = date('now')
`, [], (err, videos) => {
if (err) return reject(err);
// 오늘 전체 업로드 수 (YouTube + Instagram + TikTok)
db.get(`
SELECT
(SELECT COUNT(*) FROM upload_history WHERE date(uploaded_at) = date('now')) +
(SELECT COUNT(*) FROM instagram_upload_history WHERE date(createdAt) = date('now')) +
(SELECT COUNT(*) FROM tiktok_upload_history WHERE date(createdAt) = date('now'))
as total_uploads,
(SELECT COUNT(*) FROM upload_history WHERE date(uploaded_at) = date('now')) as youtube,
(SELECT COUNT(*) FROM instagram_upload_history WHERE date(createdAt) = date('now')) as instagram,
(SELECT COUNT(*) FROM tiktok_upload_history WHERE date(createdAt) = date('now')) as tiktok
`, [], (err, uploads) => {
if (err) return reject(err);
// 오늘 사용된 크레딧
db.get(`
SELECT COALESCE(SUM(ABS(amount)), 0) as total FROM credit_history
WHERE amount < 0 AND date(createdAt) = date('now')
`, [], (err, credits) => {
if (err) return reject(err);
// 저장 또는 업데이트
db.run(`
INSERT OR REPLACE INTO system_stats_daily
(date, total_users, new_users, active_users, total_videos_generated,
total_uploads, youtube_uploads, instagram_uploads, tiktok_uploads,
total_credits_used, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, [
today,
totalUsers.total,
newUsers.count,
activeUsers.count,
videos.count,
uploads?.total_uploads || 0,
uploads?.youtube || 0,
uploads?.instagram || 0,
uploads?.tiktok || 0,
credits.total
], function (err) {
if (err) reject(err);
else resolve({ success: true });
});
});
});
});
});
});
});
});
});
}
/**
* 사용자 성장 트렌드 조회
* @param {number} days - 조회 기간 ()
*/
function getUserGrowthTrend(days = 30) {
return new Promise((resolve, reject) => {
db.all(`
SELECT
date(createdAt) as date,
COUNT(*) as new_users,
SUM(COUNT(*)) OVER (ORDER BY date(createdAt)) as cumulative_users
FROM users
WHERE createdAt >= date('now', '-' || ? || ' days')
GROUP BY date(createdAt)
ORDER BY date ASC
`, [days], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 영상 생성 트렌드 조회
* @param {number} days - 조회 기간 ()
*/
function getVideoGenerationTrend(days = 30) {
return new Promise((resolve, reject) => {
db.all(`
SELECT
date(createdAt) as date,
COUNT(*) as videos_generated,
COUNT(DISTINCT user_id) as unique_users
FROM history
WHERE createdAt >= date('now', '-' || ? || ' days')
GROUP BY date(createdAt)
ORDER BY date ASC
`, [days], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 플랫폼별 업로드 통계
* @param {number} days - 조회 기간 ()
*/
function getPlatformUploadStats(days = 30) {
return new Promise((resolve, reject) => {
const result = {
youtube: [],
instagram: [],
tiktok: [],
summary: {}
};
db.all(`
SELECT date(uploaded_at) as date, COUNT(*) as count, status
FROM upload_history
WHERE uploaded_at >= date('now', '-' || ? || ' days')
GROUP BY date(uploaded_at), status
ORDER BY date ASC
`, [days], (err, youtubeRows) => {
if (err) return reject(err);
result.youtube = youtubeRows || [];
db.all(`
SELECT date(createdAt) as date, COUNT(*) as count, status
FROM instagram_upload_history
WHERE createdAt >= date('now', '-' || ? || ' days')
GROUP BY date(createdAt), status
ORDER BY date ASC
`, [days], (err, instaRows) => {
if (err) return reject(err);
result.instagram = instaRows || [];
db.all(`
SELECT date(createdAt) as date, COUNT(*) as count, status
FROM tiktok_upload_history
WHERE createdAt >= date('now', '-' || ? || ' days')
GROUP BY date(createdAt), status
ORDER BY date ASC
`, [days], (err, tiktokRows) => {
if (err) return reject(err);
result.tiktok = tiktokRows || [];
// 요약 통계 계산
db.get(`
SELECT
(SELECT COUNT(*) FROM upload_history WHERE uploaded_at >= date('now', '-' || ? || ' days')) as youtube_total,
(SELECT COUNT(*) FROM upload_history WHERE uploaded_at >= date('now', '-' || ? || ' days') AND status = 'completed') as youtube_success,
(SELECT COUNT(*) FROM instagram_upload_history WHERE createdAt >= date('now', '-' || ? || ' days')) as instagram_total,
(SELECT COUNT(*) FROM instagram_upload_history WHERE createdAt >= date('now', '-' || ? || ' days') AND status = 'completed') as instagram_success,
(SELECT COUNT(*) FROM tiktok_upload_history WHERE createdAt >= date('now', '-' || ? || ' days')) as tiktok_total,
(SELECT COUNT(*) FROM tiktok_upload_history WHERE createdAt >= date('now', '-' || ? || ' days') AND status = 'completed') as tiktok_success
`, [days, days, days, days, days, days], (err, summary) => {
if (err) return reject(err);
result.summary = summary || {};
resolve(result);
});
});
});
});
});
}
/**
* 크레딧 사용 통계
* @param {number} days - 조회 기간 ()
*/
function getCreditUsageStats(days = 30) {
return new Promise((resolve, reject) => {
db.all(`
SELECT
date(createdAt) as date,
type,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) as credits_added,
SUM(CASE WHEN amount < 0 THEN ABS(amount) ELSE 0 END) as credits_used
FROM credit_history
WHERE createdAt >= date('now', '-' || ? || ' days')
GROUP BY date(createdAt), type
ORDER BY date ASC
`, [days], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 플랜별 사용자 분포
*/
function getPlanDistribution() {
return new Promise((resolve, reject) => {
db.all(`
SELECT
COALESCE(plan_type, 'free') as plan,
COUNT(*) as count,
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM users), 2) as percentage
FROM users
GROUP BY plan_type
ORDER BY count DESC
`, [], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 사용자 (가장 많은 영상 생성)
* @param {number} limit - 조회 개수
*/
function getTopUsers(limit = 10) {
return new Promise((resolve, reject) => {
db.all(`
SELECT
u.id,
u.username,
u.name,
u.plan_type,
u.credits,
COUNT(h.id) as total_videos,
(SELECT COUNT(*) FROM upload_history WHERE user_id = u.id) as youtube_uploads,
(SELECT COUNT(*) FROM instagram_upload_history WHERE user_id = u.id) as instagram_uploads,
(SELECT COUNT(*) FROM tiktok_upload_history WHERE user_id = u.id) as tiktok_uploads,
(SELECT COUNT(*) FROM pension_profiles WHERE user_id = u.id) as pension_count
FROM users u
LEFT JOIN history h ON u.id = h.user_id
WHERE u.role != 'admin'
GROUP BY u.id
ORDER BY total_videos DESC
LIMIT ?
`, [limit], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 최근 활동 로그
* @param {number} limit - 조회 개수
*/
function getRecentActivityLogs(limit = 50) {
return new Promise((resolve, reject) => {
db.all(`
SELECT
al.*,
u.username,
u.name
FROM activity_logs al
LEFT JOIN users u ON al.user_id = u.id
ORDER BY al.createdAt DESC
LIMIT ?
`, [limit], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 활동 로그 기록
* @param {number|null} userId - 사용자 ID
* @param {string} actionType - 액션 타입
* @param {string} actionDetail - 액션 상세
* @param {object} metadata - 추가 메타데이터
*/
function logActivity(userId, actionType, actionDetail, metadata = {}) {
return new Promise((resolve, reject) => {
db.run(`
INSERT INTO activity_logs (user_id, action_type, action_detail, metadata, createdAt)
VALUES (?, ?, ?, ?, datetime('now'))
`, [userId, actionType, actionDetail, JSON.stringify(metadata)], function (err) {
if (err) reject(err);
else resolve({ id: this.lastID });
});
});
}
/**
* 대시보드 요약 통계
*/
function getDashboardSummary() {
return new Promise((resolve, reject) => {
const summary = {};
db.serialize(() => {
// 전체 사용자
db.get('SELECT COUNT(*) as total FROM users WHERE role != ?', ['admin'], (err, row) => {
if (err) return reject(err);
summary.totalUsers = row.total;
// 오늘 신규 가입
db.get(`
SELECT COUNT(*) as count FROM users
WHERE date(createdAt) = date('now') AND role != 'admin'
`, [], (err, row) => {
if (err) return reject(err);
summary.newUsersToday = row.count;
// 이번 주 활성 사용자
db.get(`
SELECT COUNT(DISTINCT user_id) as count FROM history
WHERE createdAt >= date('now', '-7 days')
`, [], (err, row) => {
if (err) return reject(err);
summary.activeUsersWeek = row.count;
// 전체 생성 영상 수
db.get('SELECT COUNT(*) as total FROM history', [], (err, row) => {
if (err) return reject(err);
summary.totalVideos = row.total;
// 오늘 생성 영상
db.get(`
SELECT COUNT(*) as count FROM history
WHERE date(createdAt) = date('now')
`, [], (err, row) => {
if (err) return reject(err);
summary.videosToday = row.count;
// 전체 업로드 수
db.get(`
SELECT
(SELECT COUNT(*) FROM upload_history) +
(SELECT COUNT(*) FROM instagram_upload_history) +
(SELECT COUNT(*) FROM tiktok_upload_history) as total
`, [], (err, row) => {
if (err) return reject(err);
summary.totalUploads = row.total;
// 플랫폼별 업로드
db.get(`
SELECT
(SELECT COUNT(*) FROM upload_history) as youtube,
(SELECT COUNT(*) FROM instagram_upload_history) as instagram,
(SELECT COUNT(*) FROM tiktok_upload_history) as tiktok
`, [], (err, row) => {
if (err) return reject(err);
summary.platformUploads = row;
// 전체 펜션 수
db.get('SELECT COUNT(*) as total FROM pension_profiles', [], (err, row) => {
if (err) return reject(err);
summary.totalPensions = row.total;
// 대기중인 승인 요청
db.get(`
SELECT COUNT(*) as count FROM users
WHERE approved = 0 AND role != 'admin'
`, [], (err, row) => {
if (err) return reject(err);
summary.pendingApprovals = row.count;
// 대기중인 크레딧 요청
db.get(`
SELECT COUNT(*) as count FROM credit_requests
WHERE status = 'pending'
`, [], (err, row) => {
if (err) return reject(err);
summary.pendingCreditRequests = row.count;
// 전체 크레딧 발행량
db.get(`
SELECT
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) as issued,
COALESCE(SUM(CASE WHEN amount < 0 THEN ABS(amount) ELSE 0 END), 0) as used
FROM credit_history
`, [], (err, row) => {
if (err) return reject(err);
summary.credits = row;
resolve(summary);
});
});
});
});
});
});
});
});
});
});
});
});
});
}
/**
* 시간대별 사용 패턴 분석
*/
function getUsagePattern() {
return new Promise((resolve, reject) => {
db.all(`
SELECT
strftime('%H', createdAt) as hour,
COUNT(*) as count
FROM history
WHERE createdAt >= date('now', '-30 days')
GROUP BY strftime('%H', createdAt)
ORDER BY hour ASC
`, [], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 지역별 사용자 분포 (펜션 주소 기반)
*/
function getRegionalDistribution() {
return new Promise((resolve, reject) => {
db.all(`
SELECT
COALESCE(region, '미설정') as region,
COUNT(*) as count
FROM pension_profiles
GROUP BY region
ORDER BY count DESC
LIMIT 20
`, [], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* 월별 수익 예측 (플랜 기반)
*/
function getRevenueProjection() {
return new Promise((resolve, reject) => {
const PLAN_PRICES = {
free: 0,
basic: 29000,
pro: 99000,
business: 299000
};
db.all(`
SELECT
COALESCE(plan_type, 'free') as plan,
COUNT(*) as count
FROM users
WHERE role != 'admin'
GROUP BY plan_type
`, [], (err, rows) => {
if (err) return reject(err);
let monthlyRevenue = 0;
const breakdown = {};
(rows || []).forEach(row => {
const price = PLAN_PRICES[row.plan] || 0;
const revenue = price * row.count;
monthlyRevenue += revenue;
breakdown[row.plan] = {
users: row.count,
price,
revenue
};
});
resolve({
monthlyRevenue,
annualProjection: monthlyRevenue * 12,
breakdown
});
});
});
}
/**
* 시스템 헬스 체크
*/
function getSystemHealth() {
return new Promise((resolve, reject) => {
const health = {
database: 'ok',
diskUsage: null,
lastVideoGenerated: null,
uptime: process.uptime()
};
// DB 연결 확인
db.get('SELECT 1', [], (err) => {
if (err) {
health.database = 'error';
}
// 마지막 영상 생성 시간
db.get(`
SELECT createdAt FROM history
ORDER BY createdAt DESC LIMIT 1
`, [], (err, row) => {
if (!err && row) {
health.lastVideoGenerated = row.createdAt;
}
// 디스크 사용량 (downloads 폴더)
const fs = require('fs');
const path = require('path');
const downloadsDir = path.join(__dirname, 'downloads');
try {
let totalSize = 0;
const files = fs.readdirSync(downloadsDir);
files.forEach(file => {
const filePath = path.join(downloadsDir, file);
const stats = fs.statSync(filePath);
totalSize += stats.size;
});
health.diskUsage = {
bytes: totalSize,
mb: Math.round(totalSize / 1024 / 1024),
fileCount: files.length
};
} catch (e) {
health.diskUsage = { error: e.message };
}
resolve(health);
});
});
});
}
module.exports = {
updateDailyStats,
getUserGrowthTrend,
getVideoGenerationTrend,
getPlatformUploadStats,
getCreditUsageStats,
getPlanDistribution,
getTopUsers,
getRecentActivityLogs,
logActivity,
getDashboardSummary,
getUsagePattern,
getRegionalDistribution,
getRevenueProjection,
getSystemHealth
};

Binary file not shown.

View File

@ -0,0 +1,606 @@
/**
* TikTok Content Posting API Service
* CaStAD v3.0.0
*
* TikTok API Reference: https://developers.tiktok.com/doc/content-posting-api-get-started/
*/
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const db = require('./db');
// TikTok API Endpoints
const TIKTOK_AUTH_URL = 'https://www.tiktok.com/v2/auth/authorize/';
const TIKTOK_TOKEN_URL = 'https://open.tiktokapis.com/v2/oauth/token/';
const TIKTOK_REVOKE_URL = 'https://open.tiktokapis.com/v2/oauth/revoke/';
const TIKTOK_USER_INFO_URL = 'https://open.tiktokapis.com/v2/user/info/';
const TIKTOK_UPLOAD_INIT_URL = 'https://open.tiktokapis.com/v2/post/publish/video/init/';
const TIKTOK_UPLOAD_INBOX_URL = 'https://open.tiktokapis.com/v2/post/publish/inbox/video/init/';
const TIKTOK_PUBLISH_STATUS_URL = 'https://open.tiktokapis.com/v2/post/publish/status/fetch/';
// Scopes
const TIKTOK_SCOPES = [
'user.info.basic',
'user.info.profile',
'user.info.stats',
'video.publish',
'video.upload'
];
// TikTok Client credentials (from environment or config)
const TIKTOK_CLIENT_KEY = process.env.TIKTOK_CLIENT_KEY;
const TIKTOK_CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET;
/**
* TikTok OAuth 인증 URL 생성
* @param {number} userId - 사용자 ID
* @param {string} redirectUri - 콜백 URI
*/
function generateAuthUrl(userId, redirectUri = null) {
if (!TIKTOK_CLIENT_KEY) {
throw new Error('TIKTOK_CLIENT_KEY 환경변수가 설정되지 않았습니다.');
}
const finalRedirectUri = redirectUri || `${process.env.BACKEND_URL || 'http://localhost:3001'}/api/tiktok/oauth/callback`;
const params = new URLSearchParams({
client_key: TIKTOK_CLIENT_KEY,
scope: TIKTOK_SCOPES.join(','),
response_type: 'code',
redirect_uri: finalRedirectUri,
state: JSON.stringify({ userId })
});
return `${TIKTOK_AUTH_URL}?${params.toString()}`;
}
/**
* 인증 코드로 토큰 교환
* @param {string} code - 인증 코드
* @param {number} userId - 사용자 ID
* @param {string} redirectUri - 콜백 URI
*/
async function exchangeCodeForTokens(code, userId, redirectUri = null) {
if (!TIKTOK_CLIENT_KEY || !TIKTOK_CLIENT_SECRET) {
throw new Error('TikTok 클라이언트 자격증명이 설정되지 않았습니다.');
}
const finalRedirectUri = redirectUri || `${process.env.BACKEND_URL || 'http://localhost:3001'}/api/tiktok/oauth/callback`;
try {
// 토큰 요청
const tokenResponse = await axios.post(TIKTOK_TOKEN_URL, null, {
params: {
client_key: TIKTOK_CLIENT_KEY,
client_secret: TIKTOK_CLIENT_SECRET,
code,
grant_type: 'authorization_code',
redirect_uri: finalRedirectUri
}
});
const tokens = tokenResponse.data;
if (tokens.error) {
throw new Error(tokens.error.message || 'Token exchange failed');
}
const accessToken = tokens.access_token;
const refreshToken = tokens.refresh_token;
const expiresIn = tokens.expires_in;
const openId = tokens.open_id;
const tokenExpiry = new Date(Date.now() + expiresIn * 1000).toISOString();
// 사용자 정보 가져오기
const userInfo = await getTikTokUserInfo(accessToken, openId);
// DB에 저장
return new Promise((resolve, reject) => {
db.run(`
INSERT OR REPLACE INTO tiktok_connections
(user_id, open_id, display_name, avatar_url, follower_count, following_count,
access_token, refresh_token, token_expiry, scopes, connected_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, [
userId,
openId,
userInfo.display_name || null,
userInfo.avatar_url || null,
userInfo.follower_count || 0,
userInfo.following_count || 0,
accessToken,
refreshToken,
tokenExpiry,
TIKTOK_SCOPES.join(',')
], function (err) {
if (err) {
console.error('[TikTok OAuth] 토큰 저장 실패:', err);
reject(err);
} else {
// 기본 설정 생성
db.run(`INSERT OR IGNORE INTO tiktok_settings (user_id) VALUES (?)`, [userId]);
resolve({
success: true,
openId,
displayName: userInfo.display_name,
avatarUrl: userInfo.avatar_url,
followerCount: userInfo.follower_count
});
}
});
});
} catch (error) {
console.error('[TikTok OAuth] 토큰 교환 실패:', error.response?.data || error.message);
throw error;
}
}
/**
* TikTok 사용자 정보 조회
* @param {string} accessToken - 액세스 토큰
* @param {string} openId - TikTok Open ID
*/
async function getTikTokUserInfo(accessToken, openId) {
try {
const response = await axios.get(TIKTOK_USER_INFO_URL, {
headers: {
'Authorization': `Bearer ${accessToken}`
},
params: {
fields: 'open_id,display_name,avatar_url,follower_count,following_count,bio_description'
}
});
return response.data.data?.user || {};
} catch (error) {
console.error('[TikTok] 사용자 정보 조회 실패:', error.response?.data || error.message);
return {};
}
}
/**
* 토큰 갱신
* @param {string} refreshToken - 리프레시 토큰
*/
async function refreshAccessToken(refreshToken) {
try {
const response = await axios.post(TIKTOK_TOKEN_URL, null, {
params: {
client_key: TIKTOK_CLIENT_KEY,
client_secret: TIKTOK_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: refreshToken
}
});
return response.data;
} catch (error) {
console.error('[TikTok] 토큰 갱신 실패:', error.response?.data || error.message);
throw error;
}
}
/**
* 사용자별 인증된 클라이언트 정보 가져오기
* @param {number} userId - 사용자 ID
*/
async function getAuthenticatedCredentials(userId) {
return new Promise((resolve, reject) => {
db.get(`SELECT * FROM tiktok_connections WHERE user_id = ?`, [userId], async (err, row) => {
if (err) {
reject(err);
return;
}
if (!row) {
reject(new Error('TikTok 계정이 연결되지 않았습니다. 설정에서 계정을 연결해주세요.'));
return;
}
// 토큰 만료 체크
const tokenExpiry = new Date(row.token_expiry);
const now = new Date();
if (tokenExpiry <= now) {
// 토큰 갱신 시도
try {
const newTokens = await refreshAccessToken(row.refresh_token);
const newExpiry = new Date(Date.now() + newTokens.expires_in * 1000).toISOString();
// DB 업데이트
db.run(`
UPDATE tiktok_connections
SET access_token = ?, refresh_token = ?, token_expiry = ?
WHERE user_id = ?
`, [newTokens.access_token, newTokens.refresh_token, newExpiry, userId]);
resolve({
accessToken: newTokens.access_token,
openId: row.open_id,
displayName: row.display_name
});
} catch (refreshError) {
console.error('[TikTok] 토큰 갱신 실패:', refreshError);
// 연결 해제
db.run(`DELETE FROM tiktok_connections WHERE user_id = ?`, [userId]);
reject(new Error('TikTok 인증이 만료되었습니다. 다시 연결해주세요.'));
}
} else {
resolve({
accessToken: row.access_token,
openId: row.open_id,
displayName: row.display_name
});
}
});
});
}
/**
* 사용자의 TikTok 연결 상태 확인
*/
function getConnectionStatus(userId) {
return new Promise((resolve, reject) => {
db.get(`
SELECT open_id, display_name, avatar_url, follower_count, following_count, connected_at
FROM tiktok_connections WHERE user_id = ?
`, [userId], (err, row) => {
if (err) reject(err);
else resolve(row || null);
});
});
}
/**
* TikTok 연결 해제
*/
async function disconnectTikTok(userId) {
return new Promise((resolve, reject) => {
db.get(`SELECT access_token FROM tiktok_connections WHERE user_id = ?`, [userId], async (err, row) => {
if (err) {
reject(err);
return;
}
// 토큰 폐기 시도
if (row?.access_token) {
try {
await axios.post(TIKTOK_REVOKE_URL, null, {
params: {
client_key: TIKTOK_CLIENT_KEY,
client_secret: TIKTOK_CLIENT_SECRET,
token: row.access_token
}
});
} catch (revokeError) {
console.error('[TikTok] 토큰 폐기 실패 (무시):', revokeError.message);
}
}
// DB에서 삭제
db.run(`DELETE FROM tiktok_connections WHERE user_id = ?`, [userId], function (delErr) {
if (delErr) reject(delErr);
else resolve({ success: true, deleted: this.changes });
});
});
});
}
/**
* 비디오 업로드 (Direct Post)
* @param {number} userId - 사용자 ID
* @param {string} videoPath - 비디오 파일 경로
* @param {object} metadata - 메타데이터 (title, description, etc.)
* @param {object} options - 업로드 옵션
*/
async function uploadVideo(userId, videoPath, metadata, options = {}) {
try {
const credentials = await getAuthenticatedCredentials(userId);
const fileSize = fs.statSync(videoPath).size;
// Step 1: Initialize upload
const initResponse = await axios.post(
TIKTOK_UPLOAD_INIT_URL,
{
post_info: {
title: (metadata.title || 'CaStAD Video').substring(0, 150),
privacy_level: options.privacyLevel || 'SELF_ONLY', // SELF_ONLY, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, PUBLIC_TO_EVERYONE
disable_duet: options.disableDuet || false,
disable_comment: options.disableComment || false,
disable_stitch: options.disableStitch || false,
video_cover_timestamp_ms: options.coverTimestamp || 1000
},
source_info: {
source: 'FILE_UPLOAD',
video_size: fileSize,
chunk_size: Math.min(fileSize, 10 * 1024 * 1024), // 10MB chunks
total_chunk_count: Math.ceil(fileSize / (10 * 1024 * 1024))
}
},
{
headers: {
'Authorization': `Bearer ${credentials.accessToken}`,
'Content-Type': 'application/json; charset=UTF-8'
}
}
);
if (initResponse.data.error?.code) {
throw new Error(initResponse.data.error.message || 'Upload initialization failed');
}
const uploadUrl = initResponse.data.data?.upload_url;
const publishId = initResponse.data.data?.publish_id;
if (!uploadUrl) {
throw new Error('Upload URL not received from TikTok');
}
// Step 2: Upload video file in chunks
const chunkSize = 10 * 1024 * 1024; // 10MB
const totalChunks = Math.ceil(fileSize / chunkSize);
const fileStream = fs.createReadStream(videoPath);
let uploadedBytes = 0;
let chunkIndex = 0;
for await (const chunk of fileStream) {
const start = uploadedBytes;
const end = Math.min(uploadedBytes + chunk.length - 1, fileSize - 1);
await axios.put(uploadUrl, chunk, {
headers: {
'Content-Type': 'video/mp4',
'Content-Length': chunk.length,
'Content-Range': `bytes ${start}-${end}/${fileSize}`
}
});
uploadedBytes += chunk.length;
chunkIndex++;
if (options.onProgress) {
options.onProgress((uploadedBytes / fileSize) * 100);
}
}
console.log(`[TikTok] 업로드 완료! Publish ID: ${publishId}`);
// Step 3: Check publish status
const status = await checkPublishStatus(credentials.accessToken, publishId);
// 업로드 히스토리 저장
db.run(`
INSERT INTO tiktok_upload_history
(user_id, history_id, publish_id, title, privacy_level, status, uploaded_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
`, [userId, options.historyId || null, publishId, metadata.title, options.privacyLevel || 'SELF_ONLY', status.status]);
return {
publishId,
status: status.status,
videoId: status.video_id
};
} catch (error) {
console.error('[TikTok] 업로드 오류:', error.response?.data || error.message);
// 에러 기록
db.run(`
INSERT INTO tiktok_upload_history
(user_id, history_id, status, error_message, uploaded_at)
VALUES (?, ?, 'failed', ?, datetime('now'))
`, [userId, options.historyId || null, error.message]);
throw error;
}
}
/**
* 비디오 업로드 (Inbox/Draft 방식)
* @param {number} userId - 사용자 ID
* @param {string} videoPath - 비디오 파일 경로
* @param {object} metadata - 메타데이터
* @param {object} options - 업로드 옵션
*/
async function uploadVideoToInbox(userId, videoPath, metadata, options = {}) {
try {
const credentials = await getAuthenticatedCredentials(userId);
const fileSize = fs.statSync(videoPath).size;
// Initialize inbox upload
const initResponse = await axios.post(
TIKTOK_UPLOAD_INBOX_URL,
{
source_info: {
source: 'FILE_UPLOAD',
video_size: fileSize,
chunk_size: Math.min(fileSize, 10 * 1024 * 1024),
total_chunk_count: Math.ceil(fileSize / (10 * 1024 * 1024))
}
},
{
headers: {
'Authorization': `Bearer ${credentials.accessToken}`,
'Content-Type': 'application/json; charset=UTF-8'
}
}
);
if (initResponse.data.error?.code) {
throw new Error(initResponse.data.error.message || 'Inbox upload initialization failed');
}
const uploadUrl = initResponse.data.data?.upload_url;
const publishId = initResponse.data.data?.publish_id;
// Upload file
const fileBuffer = fs.readFileSync(videoPath);
await axios.put(uploadUrl, fileBuffer, {
headers: {
'Content-Type': 'video/mp4',
'Content-Length': fileSize,
'Content-Range': `bytes 0-${fileSize - 1}/${fileSize}`
}
});
console.log(`[TikTok] Inbox 업로드 완료! Publish ID: ${publishId}`);
return {
publishId,
status: 'uploaded_to_inbox',
message: 'TikTok 앱에서 영상을 확인하고 게시해주세요.'
};
} catch (error) {
console.error('[TikTok] Inbox 업로드 오류:', error.response?.data || error.message);
throw error;
}
}
/**
* 게시 상태 확인
* @param {string} accessToken - 액세스 토큰
* @param {string} publishId - 게시 ID
*/
async function checkPublishStatus(accessToken, publishId) {
try {
const response = await axios.post(
TIKTOK_PUBLISH_STATUS_URL,
{ publish_id: publishId },
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
return response.data.data || { status: 'unknown' };
} catch (error) {
console.error('[TikTok] 상태 확인 오류:', error.response?.data || error.message);
return { status: 'unknown', error: error.message };
}
}
/**
* 사용자 TikTok 설정 조회
*/
function getUserTikTokSettings(userId) {
return new Promise((resolve, reject) => {
db.get(`SELECT * FROM tiktok_settings WHERE user_id = ?`, [userId], (err, row) => {
if (err) reject(err);
else resolve(row || {
default_privacy: 'SELF_ONLY',
disable_duet: 0,
disable_comment: 0,
disable_stitch: 0,
auto_upload: 0,
upload_to_inbox: 1
});
});
});
}
/**
* 사용자 TikTok 설정 업데이트
*/
function updateUserTikTokSettings(userId, settings) {
return new Promise((resolve, reject) => {
const fields = [];
const values = [];
const allowedFields = [
'default_privacy', 'disable_duet', 'disable_comment',
'disable_stitch', 'auto_upload', 'upload_to_inbox', 'default_hashtags'
];
for (const field of allowedFields) {
if (settings[field] !== undefined) {
fields.push(`${field} = ?`);
values.push(settings[field]);
}
}
if (fields.length === 0) {
resolve({ success: true });
return;
}
values.push(userId);
db.run(`
UPDATE tiktok_settings SET ${fields.join(', ')}, updated_at = datetime('now')
WHERE user_id = ?
`, values, function (err) {
if (err) {
// INSERT 시도
db.run(`
INSERT INTO tiktok_settings (user_id, ${fields.map(f => f.split(' = ')[0]).join(', ')})
VALUES (?, ${fields.map(() => '?').join(', ')})
`, [userId, ...values.slice(0, -1)], function (err2) {
if (err2) reject(err2);
else resolve({ success: true });
});
} else {
resolve({ success: true });
}
});
});
}
/**
* 업로드 히스토리 조회
*/
function getUploadHistory(userId, limit = 20) {
return new Promise((resolve, reject) => {
db.all(`
SELECT * FROM tiktok_upload_history
WHERE user_id = ?
ORDER BY uploaded_at DESC
LIMIT ?
`, [userId, limit], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* TikTok 통계 조회 (사용자별)
*/
function getTikTokStats(userId) {
return new Promise((resolve, reject) => {
db.get(`
SELECT
COUNT(*) as total_uploads,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as successful_uploads,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_uploads
FROM tiktok_upload_history
WHERE user_id = ?
`, [userId], (err, row) => {
if (err) reject(err);
else resolve(row || { total_uploads: 0, successful_uploads: 0, failed_uploads: 0 });
});
});
}
module.exports = {
generateAuthUrl,
exchangeCodeForTokens,
getAuthenticatedCredentials,
getConnectionStatus,
disconnectTikTok,
uploadVideo,
uploadVideoToInbox,
checkPublishStatus,
getUserTikTokSettings,
updateUserTikTokSettings,
getUploadHistory,
getTikTokStats,
TIKTOK_SCOPES
};

View File

@ -0,0 +1,610 @@
const fs = require('fs');
const path = require('path');
const { google } = require('googleapis');
const db = require('./db');
// 클라이언트 시크릿 파일 (Google Cloud Console에서 다운로드)
const CREDENTIALS_PATH = path.join(__dirname, 'client_secret.json');
const SCOPES = [
'https://www.googleapis.com/auth/youtube.upload',
'https://www.googleapis.com/auth/youtube',
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile'
];
/**
* Google OAuth2 클라이언트 생성
* @param {string} redirectUri - 콜백 URI
*/
function createOAuth2Client(redirectUri = null) {
if (!fs.existsSync(CREDENTIALS_PATH)) {
throw new Error("client_secret.json 파일이 없습니다. Google Cloud Console에서 다운로드하여 server 폴더에 넣어주세요.");
}
const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
const credentials = JSON.parse(content);
const { client_secret, client_id, redirect_uris } = credentials.installed || credentials.web;
const finalRedirectUri = redirectUri || redirect_uris[0] || 'http://localhost:3001/api/youtube/oauth/callback';
return new google.auth.OAuth2(client_id, client_secret, finalRedirectUri);
}
/**
* OAuth 인증 URL 생성
* @param {number} userId - 사용자 ID (state 파라미터로 전달)
* @param {string} redirectUri - 콜백 URI
*/
function generateAuthUrl(userId, redirectUri = null) {
const oAuth2Client = createOAuth2Client(redirectUri);
const authUrl = oAuth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
prompt: 'consent', // 항상 refresh_token 받기 위해
state: JSON.stringify({ userId }) // 콜백에서 사용자 식별용
});
return authUrl;
}
/**
* 인증 코드로 토큰 교환 저장
* @param {string} code - 인증 코드
* @param {number} userId - 사용자 ID
* @param {string} redirectUri - 콜백 URI
*/
async function exchangeCodeForTokens(code, userId, redirectUri = null) {
const oAuth2Client = createOAuth2Client(redirectUri);
try {
const { tokens } = await oAuth2Client.getToken(code);
oAuth2Client.setCredentials(tokens);
// 사용자 정보 가져오기
const oauth2 = google.oauth2({ version: 'v2', auth: oAuth2Client });
const userInfo = await oauth2.userinfo.get();
// YouTube 채널 정보 가져오기
const youtube = google.youtube({ version: 'v3', auth: oAuth2Client });
const channelRes = await youtube.channels.list({
part: 'snippet',
mine: true
});
const channel = channelRes.data.items?.[0];
// DB에 저장
const tokenExpiry = tokens.expiry_date ? new Date(tokens.expiry_date).toISOString() : null;
return new Promise((resolve, reject) => {
db.run(`
INSERT OR REPLACE INTO youtube_connections
(user_id, google_user_id, google_email, youtube_channel_id, youtube_channel_title,
access_token, refresh_token, token_expiry, scopes, connected_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, [
userId,
userInfo.data.id,
userInfo.data.email,
channel?.id || null,
channel?.snippet?.title || null,
tokens.access_token,
tokens.refresh_token,
tokenExpiry,
SCOPES.join(',')
], function(err) {
if (err) {
console.error('[YouTube OAuth] 토큰 저장 실패:', err);
reject(err);
} else {
// 기본 설정도 생성
db.run(`
INSERT OR IGNORE INTO youtube_settings (user_id) VALUES (?)
`, [userId]);
resolve({
success: true,
channelId: channel?.id,
channelTitle: channel?.snippet?.title,
email: userInfo.data.email
});
}
});
});
} catch (error) {
console.error('[YouTube OAuth] 토큰 교환 실패:', error);
throw error;
}
}
/**
* 사용자별 인증된 클라이언트 가져오기
* @param {number} userId - 사용자 ID
*/
async function getAuthenticatedClientForUser(userId) {
return new Promise((resolve, reject) => {
db.get(`
SELECT * FROM youtube_connections WHERE user_id = ?
`, [userId], async (err, row) => {
if (err) {
reject(err);
return;
}
if (!row) {
reject(new Error('YouTube 채널이 연결되지 않았습니다. 설정에서 채널을 연결해주세요.'));
return;
}
const oAuth2Client = createOAuth2Client();
oAuth2Client.setCredentials({
access_token: row.access_token,
refresh_token: row.refresh_token,
expiry_date: row.token_expiry ? new Date(row.token_expiry).getTime() : null
});
// 토큰 만료 체크 및 갱신
try {
const tokenInfo = await oAuth2Client.getAccessToken();
// 새 토큰으로 갱신되었으면 DB 업데이트
if (tokenInfo.token !== row.access_token) {
const credentials = oAuth2Client.credentials;
db.run(`
UPDATE youtube_connections
SET access_token = ?, token_expiry = ?
WHERE user_id = ?
`, [
credentials.access_token,
credentials.expiry_date ? new Date(credentials.expiry_date).toISOString() : null,
userId
]);
}
resolve(oAuth2Client);
} catch (refreshError) {
console.error('[YouTube] 토큰 갱신 실패:', refreshError);
// 연결 해제 처리
db.run(`DELETE FROM youtube_connections WHERE user_id = ?`, [userId]);
reject(new Error('YouTube 인증이 만료되었습니다. 다시 연결해주세요.'));
}
});
});
}
/**
* 사용자의 YouTube 연결 상태 확인
*/
function getConnectionStatus(userId) {
return new Promise((resolve, reject) => {
db.get(`
SELECT youtube_channel_id, youtube_channel_title, google_email, connected_at
FROM youtube_connections WHERE user_id = ?
`, [userId], (err, row) => {
if (err) reject(err);
else resolve(row || null);
});
});
}
/**
* YouTube 연결 해제
*/
function disconnectYouTube(userId) {
return new Promise((resolve, reject) => {
db.run(`DELETE FROM youtube_connections WHERE user_id = ?`, [userId], function(err) {
if (err) reject(err);
else resolve({ success: true, deleted: this.changes });
});
});
}
/**
* 사용자별 비디오 업로드
* @param {number} userId - 사용자 ID
* @param {string} videoPath - 비디오 파일 경로
* @param {object} seoData - SEO 메타데이터
* @param {object} options - 업로드 옵션
*/
async function uploadVideoForUser(userId, videoPath, seoData, options = {}) {
try {
const auth = await getAuthenticatedClientForUser(userId);
const youtube = google.youtube({ version: 'v3', auth });
// 사용자 설정 가져오기
const settings = await getUserYouTubeSettings(userId);
const fileSize = fs.statSync(videoPath).size;
// SEO 데이터 + 기본 설정 병합
const title = (seoData.title || 'CastAD 생성 영상').substring(0, 100);
const description = (seoData.description || '').substring(0, 5000);
const tags = [...(seoData.tags || []), ...(settings.default_tags ? JSON.parse(settings.default_tags) : [])];
const categoryId = options.categoryId || settings.default_category_id || '19';
const privacyStatus = options.privacyStatus || settings.default_privacy || 'private';
const res = await youtube.videos.insert({
part: 'snippet,status',
requestBody: {
snippet: {
title,
description,
tags: tags.slice(0, 500), // YouTube 태그 제한
categoryId,
},
status: {
privacyStatus,
selfDeclaredMadeForKids: false,
},
},
media: {
body: fs.createReadStream(videoPath),
},
}, {
onUploadProgress: evt => {
const progress = (evt.bytesRead / fileSize) * 100;
if (options.onProgress) options.onProgress(progress);
},
});
const videoId = res.data.id;
const youtubeUrl = `https://youtu.be/${videoId}`;
console.log(`[YouTube] 업로드 성공! ${youtubeUrl}`);
// 플레이리스트에 추가
const playlistId = options.playlistId || settings.default_playlist_id;
if (playlistId) {
try {
await addVideoToPlaylist(youtube, playlistId, videoId);
console.log(`[YouTube] 플레이리스트(${playlistId})에 추가 완료`);
} catch (playlistError) {
console.error('[YouTube] 플레이리스트 추가 실패:', playlistError.message);
}
}
// 고정 댓글 달기
if (seoData.pinnedComment) {
try {
await postPinnedComment(youtube, videoId, seoData.pinnedComment);
console.log('[YouTube] 고정 댓글 추가 완료');
} catch (commentError) {
console.error('[YouTube] 고정 댓글 실패:', commentError.message);
}
}
// 업로드 히스토리 저장
db.run(`
INSERT INTO upload_history
(user_id, history_id, youtube_video_id, youtube_url, title, privacy_status, playlist_id, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'completed')
`, [userId, options.historyId || null, videoId, youtubeUrl, title, privacyStatus, playlistId]);
return { videoId, url: youtubeUrl };
} catch (error) {
console.error('[YouTube] 업로드 오류:', error.message);
// 에러 기록
db.run(`
INSERT INTO upload_history
(user_id, history_id, status, error_message)
VALUES (?, ?, 'failed', ?)
`, [userId, options.historyId || null, error.message]);
throw error;
}
}
/**
* 고정 댓글 달기
*/
async function postPinnedComment(youtube, videoId, commentText) {
const res = await youtube.commentThreads.insert({
part: 'snippet',
requestBody: {
snippet: {
videoId,
topLevelComment: {
snippet: {
textOriginal: commentText
}
}
}
}
});
// 댓글 고정 (채널 소유자만 가능)
// Note: YouTube API에서 직접 고정은 지원하지 않음, 수동으로 해야 함
return res.data;
}
/**
* 플레이리스트에 비디오 추가
*/
async function addVideoToPlaylist(youtube, playlistId, videoId) {
await youtube.playlistItems.insert({
part: 'snippet',
requestBody: {
snippet: {
playlistId,
resourceId: {
kind: 'youtube#video',
videoId,
},
},
},
});
}
/**
* 사용자의 플레이리스트 목록 조회
*/
async function getPlaylistsForUser(userId) {
try {
const auth = await getAuthenticatedClientForUser(userId);
const youtube = google.youtube({ version: 'v3', auth });
const res = await youtube.playlists.list({
part: 'snippet,contentDetails',
mine: true,
maxResults: 50,
});
const playlists = res.data.items.map(item => ({
id: item.id,
title: item.snippet.title,
description: item.snippet.description,
itemCount: item.contentDetails.itemCount,
thumbnail: item.snippet.thumbnails?.default?.url,
}));
// 캐시 업데이트
playlists.forEach(p => {
db.run(`
INSERT OR REPLACE INTO youtube_playlists
(user_id, playlist_id, title, item_count, cached_at)
VALUES (?, ?, ?, ?, datetime('now'))
`, [userId, p.id, p.title, p.itemCount]);
});
return playlists;
} catch (error) {
console.error('[YouTube] 플레이리스트 조회 오류:', error.message);
throw error;
}
}
/**
* 플레이리스트 생성
*/
async function createPlaylistForUser(userId, title, description = '', privacyStatus = 'public') {
try {
const auth = await getAuthenticatedClientForUser(userId);
const youtube = google.youtube({ version: 'v3', auth });
const res = await youtube.playlists.insert({
part: 'snippet,status',
requestBody: {
snippet: {
title: title.substring(0, 150),
description: description.substring(0, 5000),
},
status: { privacyStatus },
},
});
const playlist = {
id: res.data.id,
title: res.data.snippet.title
};
// 캐시에 추가
db.run(`
INSERT INTO youtube_playlists
(user_id, playlist_id, title, item_count, cached_at)
VALUES (?, ?, ?, 0, datetime('now'))
`, [userId, playlist.id, playlist.title]);
return playlist;
} catch (error) {
console.error('[YouTube] 플레이리스트 생성 오류:', error.message);
throw error;
}
}
/**
* 사용자 YouTube 설정 조회
*/
function getUserYouTubeSettings(userId) {
return new Promise((resolve, reject) => {
db.get(`SELECT * FROM youtube_settings WHERE user_id = ?`, [userId], (err, row) => {
if (err) reject(err);
else resolve(row || {
default_privacy: 'private',
default_category_id: '19',
default_tags: '[]',
auto_upload: 0,
upload_timing: 'manual'
});
});
});
}
/**
* 사용자 YouTube 설정 업데이트
*/
function updateUserYouTubeSettings(userId, settings) {
return new Promise((resolve, reject) => {
const fields = [];
const values = [];
const allowedFields = [
'default_privacy', 'default_category_id', 'default_tags',
'default_hashtags', 'auto_upload', 'upload_timing',
'scheduled_day', 'scheduled_time', 'default_playlist_id', 'notify_on_upload'
];
for (const field of allowedFields) {
if (settings[field] !== undefined) {
fields.push(`${field} = ?`);
values.push(typeof settings[field] === 'object' ? JSON.stringify(settings[field]) : settings[field]);
}
}
if (fields.length === 0) {
resolve({ success: true });
return;
}
values.push(userId);
db.run(`
INSERT INTO youtube_settings (user_id, ${allowedFields.map(f => f).join(', ')})
VALUES (?, ${allowedFields.map(() => '?').join(', ')})
ON CONFLICT(user_id) DO UPDATE SET ${fields.join(', ')}, updatedAt = datetime('now')
`.replace('INSERT INTO youtube_settings (user_id, ', `UPDATE youtube_settings SET ${fields.join(', ')}, updatedAt = datetime('now') WHERE user_id = ?`).split('ON CONFLICT')[0] + ' WHERE user_id = ?', values, function(err) {
if (err) {
// INSERT 시도
db.run(`
INSERT OR REPLACE INTO youtube_settings (user_id, ${fields.map(f => f.split(' = ')[0]).join(', ')})
VALUES (?, ${fields.map(() => '?').join(', ')})
`, [userId, ...values.slice(0, -1)], function(err2) {
if (err2) reject(err2);
else resolve({ success: true });
});
} else {
resolve({ success: true });
}
});
});
}
/**
* 사용자의 업로드 히스토리 조회
*/
function getUploadHistory(userId, limit = 20) {
return new Promise((resolve, reject) => {
db.all(`
SELECT * FROM upload_history
WHERE user_id = ?
ORDER BY uploaded_at DESC
LIMIT ?
`, [userId, limit], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
// ============================================
// Legacy 함수들 (기존 코드 호환용)
// ============================================
const TOKEN_PATH = path.join(__dirname, 'tokens.json');
async function getAuthenticatedClient() {
if (!fs.existsSync(CREDENTIALS_PATH)) {
throw new Error("client_secret.json 파일이 없습니다.");
}
const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
const credentials = JSON.parse(content);
const { client_secret, client_id, redirect_uris } = credentials.installed || credentials.web;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, "http://localhost:3001/oauth2callback");
if (fs.existsSync(TOKEN_PATH)) {
const token = fs.readFileSync(TOKEN_PATH, 'utf-8');
oAuth2Client.setCredentials(JSON.parse(token));
return oAuth2Client;
}
throw new Error("YouTube 인증 토큰이 없습니다.");
}
async function uploadVideo(videoPath, seoData, playlistId = null, privacyStatus = 'public') {
// Legacy: 기존 단일 계정 방식
try {
const auth = await getAuthenticatedClient();
const youtube = google.youtube({ version: 'v3', auth });
const fileSize = fs.statSync(videoPath).size;
const title = (seoData.title || 'CastAD 생성 영상').substring(0, 100);
const description = (seoData.description || '').substring(0, 5000);
const tags = seoData.tags || ['AI', 'CastAD'];
const res = await youtube.videos.insert({
part: 'snippet,status',
requestBody: {
snippet: { title, description, tags, categoryId: '22' },
status: { privacyStatus, selfDeclaredMadeForKids: false },
},
media: { body: fs.createReadStream(videoPath) },
});
const videoId = res.data.id;
console.log(`[YouTube] 업로드 성공! https://youtu.be/${videoId}`);
if (playlistId) {
await addVideoToPlaylist(youtube, playlistId, videoId);
}
return { videoId, url: `https://youtu.be/${videoId}` };
} catch (error) {
console.error('[YouTube] 업로드 오류:', error.message);
throw error;
}
}
async function getPlaylists(maxResults = 50) {
const auth = await getAuthenticatedClient();
const youtube = google.youtube({ version: 'v3', auth });
const res = await youtube.playlists.list({ part: 'snippet,contentDetails', mine: true, maxResults });
return res.data.items.map(item => ({
id: item.id,
title: item.snippet.title,
itemCount: item.contentDetails.itemCount,
}));
}
async function createPlaylist(title, description = '', privacyStatus = 'public') {
const auth = await getAuthenticatedClient();
const youtube = google.youtube({ version: 'v3', auth });
const res = await youtube.playlists.insert({
part: 'snippet,status',
requestBody: {
snippet: { title, description },
status: { privacyStatus },
},
});
return { playlistId: res.data.id, title: res.data.snippet.title };
}
module.exports = {
// 새로운 다중 사용자 함수들
createOAuth2Client,
generateAuthUrl,
exchangeCodeForTokens,
getAuthenticatedClientForUser,
getConnectionStatus,
disconnectYouTube,
uploadVideoForUser,
getPlaylistsForUser,
createPlaylistForUser,
getUserYouTubeSettings,
updateUserYouTubeSettings,
getUploadHistory,
SCOPES,
// Legacy 함수들 (기존 코드 호환)
getAuthenticatedClient,
uploadVideo,
getPlaylists,
createPlaylist,
};

View File

@ -0,0 +1,144 @@
/// <reference lib="dom" />
/**
* Base64 Uint8Array .
* @param {string} base64 - Base64 (data URI )
* @returns {Uint8Array} -
*/
export function decodeBase64(base64: string): Uint8Array {
// `atob` 함수를 사용하여 Base64 문자열을 이진 문자열로 디코딩합니다.
const binaryString = atob(base64);
const len = binaryString.length;
// 디코딩된 바이트를 저장할 Uint8Array를 생성합니다.
const bytes = new Uint8Array(len);
// 각 문자의 ASCII 코드를 바이트 배열에 저장합니다.
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
/**
* PCM AudioBuffer .
* API .
*
* @param {Uint8Array} data - PCM (Uint8Array )
* @param {AudioContext} ctx - AudioContext
* @param {number} sampleRate - (: 24000 Hz)
* @param {number} numChannels - (: 1, )
* @returns {Promise<AudioBuffer>} - AudioBuffer
*/
export async function decodeAudioData(
data: Uint8Array,
ctx: AudioContext,
sampleRate: number = 24000,
numChannels: number = 1,
): Promise<AudioBuffer> {
// 16비트 정렬을 확인하고 필요한 경우 버퍼 크기를 조정합니다.
// (createBuffer는 짝수 길이의 버퍼를 선호할 수 있습니다.)
let buffer = data.buffer;
if (buffer.byteLength % 2 !== 0) {
const newBuffer = new ArrayBuffer(buffer.byteLength + 1);
new Uint8Array(newBuffer).set(data);
buffer = newBuffer;
}
// Int16Array로 데이터를 해석하여 16비트 PCM 데이터를 처리합니다.
const dataInt16 = new Int16Array(buffer);
// 총 프레임 수를 계산합니다 (샘플 수 / 채널 수).
const frameCount = dataInt16.length / numChannels;
// AudioBuffer를 생성합니다. (채널 수, 프레임 수, 샘플 레이트)
const audioBuffer = ctx.createBuffer(numChannels, frameCount, sampleRate);
// 각 오디오 채널에 대해 데이터를 처리합니다.
for (let channel = 0; channel < numChannels; channel++) {
// 현재 채널의 데이터를 가져옵니다 (Float32Array).
const channelData = audioBuffer.getChannelData(channel);
for (let i = 0; i < frameCount; i++) {
// Int16 값을 Float32 범위 [-1.0, 1.0]으로 변환합니다.
// 16비트 부호 있는 정수(short)의 최대값은 32767이므로 이 값으로 나눕니다.
channelData[i] = dataInt16[i * numChannels + channel] / 32768.0;
}
}
return audioBuffer;
}
/**
* AudioBuffer WAV Blob .
* Blob HTML <audio> .
*
* @param {AudioBuffer} abuffer - WAV AudioBuffer
* @param {number} len - AudioBuffer ( )
* @returns {Blob} - WAV Blob
*/
export function bufferToWaveBlob(abuffer: AudioBuffer, len: number): Blob {
const numOfChan = abuffer.numberOfChannels; // 채널 수 (예: 1=모노, 2=스테레오)
// WAV 파일의 총 길이를 계산합니다. (데이터 길이 + 헤더 길이)
// (샘플 수 * 채널 수 * 2 바이트/샘플 (16비트) + 44 바이트 (WAV 헤더))
const length = len * numOfChan * 2 + 44;
const buffer = new ArrayBuffer(length); // 전체 WAV 파일 크기의 ArrayBuffer
const view = new DataView(buffer); // 데이터를 쓰기 위한 DataView
const channels = []; // 각 채널의 데이터를 저장할 배열
let i;
let sample;
let offset = 0; // 현재 읽고 있는 샘플 오프셋
let pos = 0; // DataView에 쓰는 현재 위치
// 16비트 정수를 DataView에 쓰는 헬퍼 함수
function setUint16(data: number) {
view.setUint16(pos, data, true); // little-endian
pos += 2;
}
// 32비트 정수를 DataView에 쓰는 헬퍼 함수
function setUint32(data: number) {
view.setUint32(pos, data, true); // little-endian
pos += 4;
}
// --- WAV 헤더 작성 ---
setUint32(0x46464952); // "RIFF" chunk ID
setUint32(length - 8); // ChunkSize (파일 길이 - 8 바이트)
setUint32(0x45564157); // "WAVE" format
setUint32(0x20746d66); // "fmt " sub-chunk ID
setUint32(16); // Subchunk1Size (fmt 서브 청크 길이 = 16 바이트)
setUint16(1); // AudioFormat (PCM = 1)
setUint16(numOfChan); // NumChannels
setUint32(abuffer.sampleRate); // SampleRate
setUint32(abuffer.sampleRate * 2 * numOfChan); // ByteRate (SampleRate * NumChannels * BitsPerSample/8)
setUint16(numOfChan * 2); // BlockAlign (NumChannels * BitsPerSample/8)
setUint16(16); // BitsPerSample (현재 예제에서는 16비트로 고정)
setUint32(0x61746164); // "data" sub-chunk ID
setUint32(length - pos - 4); // Subchunk2Size (데이터 길이)
// --- 인터리브된 오디오 데이터 작성 ---
// 각 채널의 오디오 데이터를 배열에 저장
for (i = 0; i < abuffer.numberOfChannels; i++)
channels.push(abuffer.getChannelData(i));
// 각 샘플을 순회하며 채널 데이터를 인터리브하여 DataView에 작성
while (offset < len) {
for (i = 0; i < numOfChan; i++) {
// DataView에 쓰기 전 안전한 경계 검사
if (pos + 2 > length) break;
// 채널 인터리브 (예: 좌, 우, 좌, 우 ...)
const channel = channels[i];
// 채널 데이터에서 현재 오프셋의 샘플을 가져옵니다. (안전하게 접근)
const s = channel && offset < channel.length ? channel[offset] : 0;
// 샘플 값을 -1에서 1 사이로 클램프(clamp)합니다.
sample = Math.max(-1, Math.min(1, s));
// 16비트 부호 있는 정수(-32768 ~ 32767) 스케일로 변환합니다.
sample = (sample < 0 ? sample * 0x8000 : sample * 0x7FFF) | 0;
view.setInt16(pos, sample, true); // 16비트 샘플 작성 (little-endian)
pos += 2;
}
offset++; // 다음 원본 샘플로 이동
}
// 최종적으로 WAV Blob을 생성하여 반환합니다.
return new Blob([buffer], { type: 'audio/wav' });
}

View File

@ -0,0 +1,137 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';
let ffmpeg: FFmpeg | null = null; // FFmpeg 인스턴스를 저장할 변수
/**
* FFmpeg WASM .
* FFmpeg .
* FFmpeg worker .
* @returns {Promise<FFmpeg>} - FFmpeg
*/
const loadFFmpeg = async () => {
if (ffmpeg) return ffmpeg; // 이미 로드되어 있다면 기존 인스턴스 반환
ffmpeg = new FFmpeg(); // 새로운 FFmpeg 인스턴스 생성
// FFmpeg 코어 및 관련 파일의 CDN 기본 URL
const coreBaseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
const ffmpegBaseURL = 'https://unpkg.com/@ffmpeg/ffmpeg@0.12.10/dist/esm';
/**
* FFmpeg worker import .
* `@ffmpeg/ffmpeg` worker.js `./classes.js` ,
* Blob URL .
* worker.js Blob URL .
* @returns {Promise<string>} - worker Blob URL
*/
const getPatchedWorkerBlob = async () => {
try {
// 원본 worker.js 스크립트를 CDN에서 가져옵니다.
const response = await fetch(`${ffmpegBaseURL}/worker.js`);
let text = await response.text();
console.log("원본 Worker 스크립트 길이:", text.length);
// 상대 경로 임포트(예: from "./classes.js")를 절대 경로로 교체합니다.
// 정규식을 사용하여 "./classes.js" 또는 './classes.js' 패턴을 찾아 교체합니다.
const patchedText = text.replace(
/from\s*["']\.\/classes\.js["']/g,
`from "${ffmpegBaseURL}/classes.js"`
);
console.log("패치된 Worker 스크립트 길이:", patchedText.length);
// 패치된 스크립트 내용을 Blob으로 만들고, 이를 위한 URL을 생성합니다.
const blob = new Blob([patchedText], { type: 'text/javascript' });
const blobUrl = URL.createObjectURL(blob);
console.log("생성된 Worker Blob URL:", blobUrl);
return blobUrl;
} catch (e) {
console.error("Worker 스크립트 패치 실패:", e);
throw e;
}
};
// FFmpeg의 코어, WASM, worker 스크립트를 로드합니다.
// workerURL은 위에서 패치된 Blob URL을 사용합니다.
const workerBlobUrl = await getPatchedWorkerBlob(); // 패치된 worker 스크립트 URL을 먼저 생성
await ffmpeg.load({
coreURL: await toBlobURL(`${coreBaseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${coreBaseURL}/ffmpeg-core.wasm`, 'application/wasm'),
workerURL: workerBlobUrl, // 패치된 worker URL 사용
});
return ffmpeg;
};
/**
* FFmpeg WASM .
* '빠른 저장' .
* @param {string} videoUrl - URL
* @param {string} audioUrl - URL
* @param {(msg: string) => void} onProgress -
* @returns {Promise<string>} - Blob URL
*/
export const mergeVideoAndAudio = async (
videoUrl: string,
audioUrl: string,
onProgress: (msg: string) => void
): Promise<string> => {
try {
onProgress("FFmpeg 엔진 로딩 중...");
const ffmpeg = await loadFFmpeg(); // FFmpeg 인스턴스 로드
onProgress("비디오/오디오 파일 다운로드 중...");
// 비디오와 오디오 파일을 FFmpeg의 가상 파일 시스템(MEMFS)에 씁니다.
await ffmpeg.writeFile('input_video.mp4', await fetchFile(videoUrl));
// 오디오 파일의 확장자를 감지하여 올바른 파일명으로 저장합니다.
const isWav = audioUrl.endsWith('wav') || audioUrl.startsWith('blob:'); // Blob URL은 대부분 WAV임
const audioExt = isWav ? 'wav' : 'mp3';
const audioFilename = `input_audio.${audioExt}`;
await ffmpeg.writeFile(audioFilename, await fetchFile(audioUrl));
onProgress("비디오/오디오 병합 및 렌더링 중 (무한 루프 & 오디오 길이 맞춤)...");
// FFmpeg 명령어 실행
// -stream_loop -1 : 비디오를 무한 반복 재생합니다. (오디오가 끝나면 멈추도록 -shortest와 함께 사용)
// -i input_video.mp4 : 첫 번째 입력 파일 (비디오)
// -i input_audio.wav : 두 번째 입력 파일 (오디오)
// -shortest : 가장 짧은 스트림(여기서는 오디오)의 길이에 맞춰 출력을 중단합니다.
// -map 0:v:0 : 첫 번째 입력(비디오)의 비디오 스트림을 출력에 매핑합니다.
// -map 1:a:0 : 두 번째 입력(오디오)의 오디오 스트림을 출력에 매핑합니다.
// -c:v libx264 : 비디오 코덱을 H.264로 재인코딩합니다 (호환성 및 압축).
// -preset ultrafast : 인코딩 속도를 최우선으로 설정합니다 (빠른 미리보기용).
// -c:a aac : 오디오 코덱을 AAC로 재인코딩합니다 (MP4 표준 오디오 코덱).
// -strict experimental : 실험적인 기능(예: AAC 인코더) 사용을 허용합니다.
await ffmpeg.exec([
'-stream_loop', '-1',
'-i', 'input_video.mp4',
'-i', audioFilename,
'-shortest',
'-map', '0:v:0',
'-map', '1:a:0',
'-c:v', 'libx264',
'-preset', 'ultrafast',
'-c:a', 'aac',
'-strict', 'experimental',
'output.mp4'
]);
onProgress("최종 파일 생성 중...");
// FFmpeg 가상 파일 시스템에서 결과 파일(output.mp4)을 읽어옵니다.
const data = await ffmpeg.readFile('output.mp4');
// 읽어온 데이터를 Blob으로 만들고, 이를 위한 URL을 생성하여 반환합니다.
const blob = new Blob([data], { type: 'video/mp4' });
return URL.createObjectURL(blob);
} catch (error: any) {
console.error("FFmpeg 병합 오류 발생:", error);
throw new Error(`영상 합성 중 오류가 발생했습니다: ${error.message}`);
}
};

View File

@ -0,0 +1,380 @@
import { BusinessInfo, TTSConfig, AspectRatio, Language, BusinessDNA } from '../types';
import { decodeBase64, decodeAudioData, bufferToWaveBlob } from './audioUtils';
const SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'];
// Helper to convert File object to base64 Data URL (MIME type 포함)
export const fileToBase64 = (file: File): Promise<{ base64: string; mimeType: string }> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const result = reader.result as string;
const [header, base64] = result.split(',');
const mimeType = header.split(':')[1].split(';')[0];
resolve({ base64, mimeType });
};
reader.onerror = error => reject(error);
});
};
/**
* (Gemini 2.5 Flash + Google Maps Tool) -
* @param {string} query - ( )
* @returns {Promise<{name: string, description: string, mapLink?: string}>}
*/
export const searchBusinessInfo = async (
query: string
): Promise<{ name: string; description: string; mapLink?: string }> => {
try {
const response = await fetch('/api/gemini/search-business', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}` // 인증 토큰 포함
},
body: JSON.stringify({ query })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
return data;
} catch (e: any) {
console.error("Search Business (Frontend) Failed:", e);
throw new Error(e.message || "업체 정보 검색에 실패했습니다.");
}
};
/**
* ( & /) -
* @param {BusinessInfo} info -
* @returns {Promise<{adCopy: string[], lyrics: string}>}
*/
export const generateCreativeContent = async (
info: BusinessInfo
): Promise<{ adCopy: string[]; lyrics: string }> => {
try {
// 이미지 File 객체를 Base64로 변환하여 백엔드로 전달
const imagesForBackend = await Promise.all(
info.images.map(async (file) => await fileToBase64(file))
);
const payload = { ...info, images: imagesForBackend };
const response = await fetch('/api/gemini/creative-content', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
return data;
} catch (e: any) {
console.error("Generate Creative Content (Frontend) Failed:", e);
throw new Error(e.message || "창의적 콘텐츠 생성에 실패했습니다.");
}
};
// 사용자 설정(성별/톤)을 Gemini 미리 정의된 목소리 이름으로 매핑 (이 함수는 백엔드에서 사용)
// const getVoiceName = (config: TTSConfig): string => { ... };
/**
* (TTS) -
* @param {string} text -
* @param {TTSConfig} config - TTS
* @returns {Promise<string>} - Base64 URL
*/
export const generateAdvancedSpeech = async (
text: string,
config: TTSConfig
): Promise<string> => {
try {
const response = await fetch('/api/gemini/speech', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ text, config })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
// Base64 오디오 데이터를 Blob URL로 변환하여 반환
const audioContext = new ((window as any).AudioContext || (window as any).webkitAudioContext)({ sampleRate: 24000 });
const audioBytes = decodeBase64(data.base64Audio);
const audioBuffer = await decodeAudioData(audioBytes, audioContext, 24000, 1);
const wavBlob = bufferToWaveBlob(audioBuffer, audioBuffer.length);
return URL.createObjectURL(wavBlob);
} catch (e: any) {
console.error("Generate Advanced Speech (Frontend) Failed:", e);
throw new Error(e.message || "성우 음성 생성에 실패했습니다.");
}
};
/**
* -
* @param {BusinessInfo} info -
* @returns {Promise<{ blobUrl: string; base64: string; mimeType: string }>}
*/
export const generateAdPoster = async (
info: BusinessInfo
): Promise<{ blobUrl: string; base64: string; mimeType: string }> => {
try {
const imagesForBackend = await Promise.all(
info.images.map(async (file) => await fileToBase64(file))
);
const payload = { ...info, images: imagesForBackend };
const response = await fetch('/api/gemini/ad-poster', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ info: payload })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
// Base64를 Blob URL로 변환
const byteCharacters = atob(data.base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: data.mimeType });
const blobUrl = URL.createObjectURL(blob);
return { blobUrl, base64: data.base64, mimeType: data.mimeType };
} catch (e: any) {
console.error("Generate Ad Poster (Frontend) Failed:", e);
throw new Error(e.message || "광고 포스터 생성에 실패했습니다.");
}
};
/**
* (/) -
* @param {BusinessInfo} info -
* @param {number} count -
* @returns {Promise<string[]>} - Base64 Data URL
*/
export const generateImageGallery = async (
info: BusinessInfo,
count: number
): Promise<string[]> => {
try {
const response = await fetch('/api/gemini/image-gallery', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ info, count })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
return data.images;
} catch (e: any) {
console.error("Generate Image Gallery (Frontend) Failed:", e);
throw new Error(e.message || "이미지 갤러리 생성에 실패했습니다.");
}
};
/**
* -
* @param {string} posterBase64 - Base64
* @param {string} posterMimeType - MIME
* @param {AspectRatio} aspectRatio -
* @returns {Promise<string>} - URL
*/
export const generateVideoBackground = async (
posterBase64: string,
posterMimeType: string,
aspectRatio: AspectRatio = '16:9'
): Promise<string> => {
try {
const response = await fetch('/api/gemini/video-background', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ posterBase64, posterMimeType, aspectRatio })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
// 서버에서 받은 비디오 URL에 API 키를 붙이지 않고 그대로 반환
return data.videoUrl;
} catch (e: any) {
console.error("Generate Video Background (Frontend) Failed:", e);
throw new Error(e.message || "비디오 배경 생성에 실패했습니다.");
}
};
/**
* AI (Gemini Vision) -
* @param {Array<object>} imagesData - Base64 ({ mimeType, base64 })
* @returns {Promise<Array<object>>} - Base64
*/
export const filterBestImages = async (
imagesData: { base64: string; mimeType: string }[]
): Promise<{ base64: string; mimeType: string }[]> => {
try {
const response = await fetch('/api/gemini/filter-images', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ imagesData })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
return data.filteredImages;
} catch (e: any) {
console.error("Filter Best Images (Frontend) Failed:", e);
throw new Error(e.message || "이미지 검수에 실패했습니다.");
}
};
/**
* -
* @param {string} name -
* @param {string} rawDescription -
* @param {string[]} reviews -
* @param {number} rating -
* @returns {Promise<string>} -
*/
export const enrichDescriptionWithReviews = async (
name: string,
rawDescription: string,
reviews: string[],
rating: number
): Promise<string> => {
try {
const response = await fetch('/api/gemini/enrich-description', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ name, rawDescription, reviews, rating })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
return data.enrichedDescription;
} catch (e: any) {
console.error("Enrich Description (Frontend) Failed:", e);
throw new Error(e.message || "마케팅 설명 생성에 실패했습니다.");
}
};
/**
* (CSS) -
* @param {File} imageFile - File
* @returns {Promise<string>} - CSS
*/
export const extractTextEffectFromImage = async (
imageFile: File
): Promise<string> => {
try {
const imageForBackend = await fileToBase64(imageFile);
const response = await fetch('/api/gemini/text-effect', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ imageFile: imageForBackend })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server error: ${response.statusText}`);
}
const data = await response.json();
return data.cssCode;
} catch (e: any) {
console.error("Extract Text Effect (Frontend) Failed:", e);
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

@ -0,0 +1,259 @@
import { BusinessInfo } from '../types';
/**
* ( + ).
*/
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}`;
};
export interface CrawlOptions {
maxImages?: number;
existingFingerprints?: Set<string>;
}
/**
* Google Maps URL .
* URL :
* - https://maps.google.com/maps?q=장소이름
* - https://www.google.com/maps/place/장소이름
* - https://goo.gl/maps/...
* - https://maps.app.goo.gl/...
*/
export const parseGoogleMapsUrl = (url: string): string | null => {
try {
// maps.google.com 또는 google.com/maps 형식
if (url.includes('google.com/maps') || url.includes('maps.google.com')) {
// /place/장소이름 형식
const placeMatch = url.match(/\/place\/([^\/\?]+)/);
if (placeMatch) {
return decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
}
// ?q=장소이름 형식
const urlObj = new URL(url);
const query = urlObj.searchParams.get('q');
if (query) {
return decodeURIComponent(query.replace(/\+/g, ' '));
}
}
return null;
} catch {
return null;
}
};
/**
* Google Maps URL .
*/
export const crawlGooglePlace = async (
urlOrQuery: string,
onProgress?: (msg: string) => void,
options?: CrawlOptions
): Promise<Partial<BusinessInfo>> => {
const maxImages = options?.maxImages ?? 100;
const existingFingerprints = options?.existingFingerprints ?? new Set<string>();
onProgress?.("Google 지도 정보 가져오는 중...");
try {
// URL에서 검색어 추출 시도
let query = parseGoogleMapsUrl(urlOrQuery);
// URL 파싱 실패 시 직접 검색어로 사용
if (!query) {
// URL 형식이 아니면 검색어로 간주
if (!urlOrQuery.startsWith('http')) {
query = urlOrQuery;
} else {
throw new Error("지원되지 않는 Google Maps URL 형식입니다.");
}
}
onProgress?.(`'${query}' 검색 중...`);
// Google Places API로 검색
const placeDetails = await searchPlaceDetails(query);
if (!placeDetails) {
throw new Error("Google Places에서 해당 장소를 찾을 수 없습니다.");
}
const totalPhotos = placeDetails.photos?.length || 0;
onProgress?.(`'${placeDetails.displayName.text}' 정보 수신 완료. 이미지 다운로드 중... (총 ${totalPhotos}장 중 최대 ${maxImages}장)`);
// 사진을 랜덤하게 섞기
const photos = placeDetails.photos || [];
const shuffledPhotos = [...photos].sort(() => Math.random() - 0.5);
// 사진 다운로드 (중복 검사 포함)
const imageFiles: File[] = [];
let skippedDuplicates = 0;
for (let i = 0; i < shuffledPhotos.length && imageFiles.length < maxImages; i++) {
try {
onProgress?.(`이미지 다운로드 중 (${imageFiles.length + 1}/${maxImages})...`);
const file = await fetchPlacePhoto(shuffledPhotos[i].name);
if (file) {
const newFile = new File([file], `google_${i}.jpg`, { type: 'image/jpeg' });
// 중복 검사
const fingerprint = await getImageFingerprint(newFile);
if (existingFingerprints.has(fingerprint)) {
console.log(`중복 이미지 발견, 건너뜁니다`);
skippedDuplicates++;
continue;
}
imageFiles.push(newFile);
}
} catch (e) {
console.warn("이미지 다운로드 실패:", e);
}
}
if (skippedDuplicates > 0) {
console.log(`${skippedDuplicates}장의 중복 이미지를 건너뛰었습니다.`);
}
onProgress?.(`${imageFiles.length}장의 이미지를 가져왔습니다.`);
// 설명 생성
let description = '';
if (placeDetails.generativeSummary?.overview?.text) {
description = placeDetails.generativeSummary.overview.text;
} else if (placeDetails.reviews && placeDetails.reviews.length > 0) {
description = placeDetails.reviews[0].text.text;
}
return {
name: placeDetails.displayName.text,
description: description || `${placeDetails.displayName.text} - ${placeDetails.primaryTypeDisplayName?.text || ''} (${placeDetails.formattedAddress})`,
images: imageFiles,
address: placeDetails.formattedAddress,
category: placeDetails.primaryTypeDisplayName?.text,
sourceUrl: urlOrQuery
};
} catch (error: any) {
console.error("Google Places 크롤링 실패:", error);
throw new Error(error.message || "Google Places 정보를 가져오는데 실패했습니다.");
}
};
interface PlaceDetails {
displayName: { text: string };
formattedAddress: string;
rating?: number;
userRatingCount?: number;
reviews?: {
name: string;
relativePublishTimeDescription: string;
text: { text: string };
rating: number;
}[];
photos?: {
name: string;
widthPx: number;
heightPx: number;
}[];
generativeSummary?: { overview: { text: string } };
primaryTypeDisplayName?: { text: string };
}
/**
* Google Places API .
*/
export const searchPlaceDetails = async (query: string): Promise<PlaceDetails | null> => {
try {
// 1. 텍스트 검색 (Text Search) - 백엔드 프록시 사용
const searchRes = await fetch(`/api/google/places/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
textQuery: query,
languageCode: "ko"
})
});
if (!searchRes.ok) {
const errorData = await searchRes.json();
throw new Error(errorData.error || `Server error: ${searchRes.statusText}`);
}
const searchData = await searchRes.json();
if (!searchData.places || searchData.places.length === 0) return null;
const placeId = searchData.places[0].name.split('/')[1];
// 2. 상세 정보 요청 (Details) - 백엔드 프록시 사용
const fieldMask = [
'displayName',
'formattedAddress',
'rating',
'userRatingCount',
'reviews',
'photos',
'primaryTypeDisplayName'
].join(',');
const detailRes = await fetch(`/api/google/places/details`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
placeId,
fieldMask
})
});
if (!detailRes.ok) {
const errorData = await detailRes.json();
throw new Error(errorData.error || `Server error: ${detailRes.statusText}`);
}
return await detailRes.json();
} catch (error: any) {
console.error("Google Places API Error (Frontend):", error);
throw new Error(error.message || "Google Places 정보를 가져오는데 실패했습니다.");
}
};
/**
* (media key) Blob .
* .
*/
export const fetchPlacePhoto = async (photoName: string, maxWidth = 800): Promise<File | null> => {
try {
if (!photoName) return null;
const response = await fetch('/api/google/places/photo', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ photoName, maxWidthPx: maxWidth })
});
if (!response.ok) return null;
const blob = await response.blob();
return new File([blob], "place_photo.jpg", { type: "image/jpeg" });
} catch (error: any) {
console.error("Photo Fetch Error (Frontend):", error);
return null;
}
};

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

@ -0,0 +1,153 @@
import { BusinessInfo } from '../types';
/**
* : URL File .
* CORS Blob URL .
* @param {string} url - URL
* @param {string} filename - File
* @returns {Promise<File>} - File
*/
const urlToFile = async (url: string, filename: string): Promise<File> => {
try {
// 백엔드 프록시를 통해 이미지 다운로드 (CORS 우회)
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); // 첫 1KB만 읽기
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; // 가져올 최대 이미지 수 (기본값: 100)
existingFingerprints?: Set<string>; // 중복 검사용 기존 이미지 fingerprints
}
/**
* .
* API .
* @param {string} url - URL ID
* @param {(msg: string) => void} [onProgress] - ( )
* @param {CrawlOptions} [options] - (maxImages, existingFingerprints)
* @returns {Promise<Partial<BusinessInfo>>} -
*/
export const crawlNaverPlace = 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/naver/crawl 엔드포인트 호출
const response = await fetch('/api/naver/crawl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }) // 크롤링할 URL을 백엔드로 전송
});
if (!response.ok) {
// 서버 응답이 성공적이지 않을 경우 에러 처리
const errData = await response.json().catch(() => ({})); // 에러 JSON 파싱 시도
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})...`);
// 각 이미지 URL을 File 객체로 변환
const file = await urlToFile(imgUrl, `naver_${data.place_id}_${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} - ${data.category} (${data.address})`,
images: imageFiles,
address: data.address,
category: data.category,
sourceUrl: url
};
} catch (error: any) {
console.error("크롤링 실패:", error);
throw new Error(error.message || "네이버 플레이스 정보를 가져오는데 실패했습니다.");
}
};

View File

@ -0,0 +1,117 @@
// Suno API 응답 인터페이스 (백엔드 응답용)
interface SunoBackendResponse {
audioUrl: string;
}
/**
* (Python _sanitize_lyrics JavaScript ).
* Suno AI [Verse 1] .
* @param {string} lyrics -
* @returns {string} - Suno AI
*/
const sanitizeLyrics = (lyrics: string): string => {
// 섹션 헤더를 감지하는 정규식 (예: Verse, Chorus, Bridge, Hook, Intro, Outro)
const sectionPattern = /^(verse|chorus|bridge|hook|intro|outro)\s*(\d+)?\s*:?/i;
const sanitizedLines: string[] = []; // 정제된 가사 라인들을 저장할 배열
const lines = lyrics.split('\n'); // 가사를 줄 단위로 분리
for (const rawLine of lines) {
const line = rawLine.trim(); // 각 줄의 앞뒤 공백 제거
if (!line) {
sanitizedLines.push(""); // 빈 줄은 그대로 추가
continue;
}
const match = line.match(sectionPattern); // 섹션 패턴 매칭 시도
if (match) {
// 섹션 이름 첫 글자 대문자화
const name = match[1].charAt(0).toUpperCase() + match[1].slice(1).toLowerCase();
const number = match[2] || ""; // 섹션 번호 (있으면 사용, 없으면 빈 문자열)
// [Verse 1] 형태의 태그로 변환하여 추가
const label = `[${name}${number ? ' ' + number : ''}]`;
sanitizedLines.push(label);
} else {
sanitizedLines.push(line); // 패턴에 해당하지 않으면 원본 줄 추가
}
}
const result = sanitizedLines.join('\n').trim(); // 정제된 줄들을 다시 합치고 최종 공백 제거
if (!result) throw new Error("정제된 가사가 비어있습니다. Suno AI에 전달할 유효한 가사가 필요합니다.");
return result;
};
/**
* Suno AI .
* (/api/suno/generate) CORS .
*
* @param {string} rawLyrics -
* @param {string} style - (: Pop, Rock, Acoustic )
* @param {string} title -
* @param {boolean} isFullSong - ( , API )
* @returns {Promise<string>} - URL
*/
export const generateSunoMusic = async (
rawLyrics: string,
style: string,
title: string,
isFullSong: boolean, // 현재는 사용되지 않음
isInstrumental: boolean = false // 연주곡 여부
): Promise<string> => {
// 1. 가사 정제 (연주곡이 아닐 때만 수행)
let sanitizedLyrics = "";
if (!isInstrumental) {
sanitizedLyrics = sanitizeLyrics(rawLyrics);
} else {
// console.log("연주곡 모드: 가사 생성 생략"); // 제거
}
// 2. 요청 페이로드 구성 (Suno OpenAPI v1 Spec 준수)
const payload = {
// Custom Mode에서 instrumental이 false이면 prompt는 가사로 사용됨.
// instrumental이 true이면 prompt는 사용되지 않음 (하지만 예제에는 포함되어 있으므로 안전하게 유지).
prompt: isInstrumental ? "" : sanitizedLyrics,
style: style, // tags -> style 로 복구
title: title.substring(0, 80), // V4/V4_5ALL 기준 80자 제한 안전하게 적용
customMode: true,
instrumental: isInstrumental, // make_instrumental -> instrumental 로 복구
model: "V5", // 필수 필드: V5 사용
callBackUrl: "https://api.example.com/callback" // 필수 필드: 더미 URL이라도 보내야 함
};
try {
// console.log("백엔드를 통해 Suno 음악 생성 요청 시작..."); // 제거
// 백엔드 엔드포인트 호출 (프록시 사용 X, 로컬 서버 사용)
const response = await fetch('/api/suno/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}` // 인증 토큰 포함
},
body: JSON.stringify(payload) // JSON 페이로드 전송
});
if (!response.ok) {
const errorJson = await response.json().catch(() => ({ error: "Unknown Error" }));
console.error("Suno Backend Error:", errorJson);
throw new Error(`음악 생성 실패 (서버): ${errorJson.error || response.statusText}`);
}
const resData: SunoBackendResponse = await response.json();
if (!resData.audioUrl) {
throw new Error("서버에서 오디오 URL을 반환하지 않았습니다.");
}
console.log(`생성 완료! Audio URL: ${resData.audioUrl}`);
return resData.audioUrl;
} catch (e: any) {
console.error("Suno 서비스 오류 발생", e);
throw new Error(e.message || "음악 생성 중 알 수 없는 오류가 발생했습니다.");
}
};

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 ""

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;

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,172 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { cn } from '../lib/utils';
import {
Sparkles,
Video,
Music,
Image as ImageIcon,
Wand2,
ChevronRight,
ChevronLeft,
Rocket
} from 'lucide-react';
const ONBOARDING_KEY = 'castad-onboarding-completed';
interface OnboardingStep {
icon: React.ReactNode;
title: string;
description: string;
image?: string;
}
const STEPS: OnboardingStep[] = [
{
icon: <Sparkles className="w-12 h-12 text-primary" />,
title: 'CastAD Pro에 오신 것을 환영합니다!',
description: 'AI 기반 펜션 홍보 영상 제작 플랫폼입니다. 단 몇 분 만에 전문적인 숏폼 영상을 만들어보세요.',
},
{
icon: <ImageIcon className="w-12 h-12 text-primary" />,
title: '펜션 정보 입력',
description: '펜션 유형을 선택하고, 이름과 URL을 입력하세요. 네이버 플레이스 URL을 입력하면 정보를 자동으로 가져옵니다.',
},
{
icon: <Wand2 className="w-12 h-12 text-accent" />,
title: 'AI가 자동으로 제작',
description: '이미지, 텍스트, 음악을 AI가 분석하고 최적화된 홍보 영상을 자동으로 생성합니다.',
},
{
icon: <Video className="w-12 h-12 text-green-500" />,
title: '다양한 스타일 선택',
description: '11가지 텍스트 이펙트, 다양한 전환 효과, AI 음악 또는 TTS 내레이션 중 선택하세요.',
},
{
icon: <Rocket className="w-12 h-12 text-primary" />,
title: '시작할 준비가 되셨나요?',
description: '첫 번째 프로젝트를 만들어보세요. 언제든지 도움이 필요하면 물어보세요!',
},
];
interface OnboardingDialogProps {
onComplete?: () => void;
}
const OnboardingDialog: React.FC<OnboardingDialogProps> = ({ onComplete }) => {
const [open, setOpen] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
useEffect(() => {
const completed = localStorage.getItem(ONBOARDING_KEY);
if (!completed) {
// Small delay to let the app render first
const timer = setTimeout(() => setOpen(true), 500);
return () => clearTimeout(timer);
}
}, []);
const handleNext = () => {
if (currentStep < STEPS.length - 1) {
setCurrentStep(currentStep + 1);
} else {
handleComplete();
}
};
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const handleComplete = () => {
localStorage.setItem(ONBOARDING_KEY, 'true');
setOpen(false);
onComplete?.();
};
const handleSkip = () => {
localStorage.setItem(ONBOARDING_KEY, 'true');
setOpen(false);
};
const step = STEPS[currentStep];
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader className="text-center pb-4">
{/* Step Dots */}
<div className="flex items-center justify-center gap-2 mb-6">
{STEPS.map((_, idx) => (
<div
key={idx}
className={cn(
"w-2 h-2 rounded-full transition-all",
idx === currentStep ? "w-6 bg-primary" : "bg-muted"
)}
/>
))}
</div>
{/* Icon */}
<div className="w-20 h-20 mx-auto rounded-2xl bg-gradient-to-br from-primary/20 to-accent/20 flex items-center justify-center mb-4">
{step.icon}
</div>
<DialogTitle className="text-xl font-bold">
{step.title}
</DialogTitle>
<DialogDescription className="text-base mt-2">
{step.description}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col sm:flex-row gap-2 pt-4">
<div className="flex items-center justify-between w-full gap-2">
{currentStep > 0 ? (
<Button variant="ghost" onClick={handlePrev}>
<ChevronLeft className="w-4 h-4 mr-1" />
</Button>
) : (
<Button variant="ghost" onClick={handleSkip}>
</Button>
)}
<Button onClick={handleNext}>
{currentStep < STEPS.length - 1 ? (
<>
<ChevronRight className="w-4 h-4 ml-1" />
</>
) : (
<>
<Rocket className="w-4 h-4 ml-1" />
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default OnboardingDialog;
// Helper to reset onboarding (for testing)
export const resetOnboarding = () => {
localStorage.removeItem(ONBOARDING_KEY);
};

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

@ -0,0 +1,526 @@
import React, { useState, useEffect } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
import { useAuth } from '../contexts/AuthContext';
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 { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Separator } from './ui/separator';
import {
Youtube,
Loader2,
Sparkles,
Globe,
Tag,
Clock,
MessageSquare,
Image as ImageIcon,
Copy,
Check,
RefreshCw,
Upload,
Link as LinkIcon,
X
} from 'lucide-react';
interface YouTubeSEOPreviewProps {
isOpen: boolean;
onClose: () => void;
businessInfo: {
businessName: string;
description: string;
categories: string[];
address: string;
language: string;
};
videoPath?: string;
videoDuration?: number;
onUpload?: (seoData: any) => void;
}
interface SEOData {
snippet: {
title_ko: string;
title_en?: string;
description_ko: string;
description_en?: string;
tags_ko: string[];
tags_en?: string[];
hashtags_ko: string[];
categoryId: string;
[key: string]: any;
};
chapters: Array<{
time: string;
title_ko: string;
title_en?: string;
[key: string]: any;
}>;
thumbnail_text: {
short_ko: string;
short_en?: string;
sub_ko: string;
sub_en?: string;
[key: string]: any;
};
pinned_comment_ko: string;
pinned_comment_en?: string;
meta?: any;
[key: string]: any;
}
const YouTubeSEOPreview: React.FC<YouTubeSEOPreviewProps> = ({
isOpen,
onClose,
businessInfo,
videoPath,
videoDuration = 60,
onUpload
}) => {
const { t } = useLanguage();
const { token } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [seoData, setSeoData] = useState<SEOData | null>(null);
const [bookingUrl, setBookingUrl] = useState('');
const [copied, setCopied] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('korean');
// 추가 언어 코드 (KO 외)
const additionalLangCode = businessInfo.language !== 'KO' ? businessInfo.language.toLowerCase() : 'en';
useEffect(() => {
if (isOpen && !seoData) {
generateSEO();
}
}, [isOpen]);
const generateSEO = async () => {
setIsGenerating(true);
try {
const response = await fetch('/api/youtube/seo', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
businessName: businessInfo.businessName,
description: businessInfo.description,
categories: businessInfo.categories,
address: businessInfo.address,
bookingUrl: bookingUrl,
videoDuration: videoDuration,
language: businessInfo.language
})
});
if (response.ok) {
const data = await response.json();
setSeoData(data);
} else {
console.error('SEO 생성 실패');
}
} catch (error) {
console.error('SEO 생성 오류:', error);
} finally {
setIsGenerating(false);
}
};
const handleCopy = (text: string, key: string) => {
navigator.clipboard.writeText(text);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
};
const updateSeoField = (path: string, value: any) => {
if (!seoData) return;
const keys = path.split('.');
const newData = { ...seoData };
let current: any = newData;
for (let i = 0; i < keys.length - 1; i++) {
current[keys[i]] = { ...current[keys[i]] };
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
setSeoData(newData);
};
const handleUpload = () => {
if (seoData && onUpload) {
onUpload(seoData);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4 overflow-y-auto">
<div className="bg-background rounded-xl max-w-5xl w-full max-h-[95vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-red-500/10">
<Youtube className="w-6 h-6 text-red-500" />
</div>
<div>
<h2 className="text-xl font-bold">YouTube SEO </h2>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isGenerating ? (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 className="w-12 h-12 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">AI SEO ...</p>
<p className="text-sm text-muted-foreground"> </p>
</div>
) : seoData ? (
<div className="space-y-6">
{/* Booking URL Input */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<LinkIcon className="w-4 h-4" />
URL
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
placeholder="https://booking.example.com/pension-name"
value={bookingUrl}
onChange={(e) => setBookingUrl(e.target.value)}
className="flex-1"
/>
<Button variant="outline" onClick={generateSEO}>
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
URL
</p>
</CardContent>
</Card>
{/* Language Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="korean" className="flex items-center gap-2">
🇰🇷
</TabsTrigger>
<TabsTrigger value="additional" className="flex items-center gap-2">
<Globe className="w-4 h-4" />
{businessInfo.language === 'EN' ? '🇺🇸 English' :
businessInfo.language === 'JA' ? '🇯🇵 日本語' :
businessInfo.language === 'ZH' ? '🇨🇳 中文' :
businessInfo.language === 'TH' ? '🇹🇭 ไทย' :
businessInfo.language === 'VI' ? '🇻🇳 Tiếng Việt' : '🇺🇸 English'}
</TabsTrigger>
</TabsList>
{/* Korean Tab */}
<TabsContent value="korean" className="space-y-4 mt-4">
{/* Title */}
<div className="space-y-2">
<Label className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{seoData.snippet.title_ko?.length || 0}/100
</span>
</Label>
<div className="flex gap-2">
<Input
value={seoData.snippet.title_ko || ''}
onChange={(e) => updateSeoField('snippet.title_ko', e.target.value)}
maxLength={100}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopy(seoData.snippet.title_ko, 'title_ko')}
>
{copied === 'title_ko' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
{/* Description */}
<div className="space-y-2">
<Label className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{seoData.snippet.description_ko?.length || 0}/5000
</span>
</Label>
<div className="flex gap-2">
<Textarea
value={seoData.snippet.description_ko || ''}
onChange={(e) => updateSeoField('snippet.description_ko', e.target.value)}
rows={10}
className="font-mono text-sm"
maxLength={5000}
/>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => handleCopy(seoData.snippet.description_ko, 'desc_ko')}
>
{copied === 'desc_ko' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
{/* Tags */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Tag className="w-4 h-4" />
<span className="text-xs text-muted-foreground">
({seoData.snippet.tags_ko?.length || 0})
</span>
</Label>
<div className="flex flex-wrap gap-1.5">
{seoData.snippet.tags_ko?.map((tag, i) => (
<Badge key={i} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
<Input
placeholder="태그 추가 (쉼표로 구분)"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const input = e.currentTarget;
const newTags = input.value.split(',').map(t => t.trim()).filter(t => t);
if (newTags.length > 0) {
updateSeoField('snippet.tags_ko', [...(seoData.snippet.tags_ko || []), ...newTags]);
input.value = '';
}
}
}}
/>
</div>
{/* Hashtags */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-1.5">
{seoData.snippet.hashtags_ko?.map((tag, i) => (
<Badge key={i} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
{/* Pinned Comment */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
</Label>
<Textarea
value={seoData.pinned_comment_ko || ''}
onChange={(e) => updateSeoField('pinned_comment_ko', e.target.value)}
rows={4}
/>
</div>
</TabsContent>
{/* Additional Language Tab */}
<TabsContent value="additional" className="space-y-4 mt-4">
{/* Title */}
<div className="space-y-2">
<Label className="flex items-center justify-between">
Title
<span className="text-xs text-muted-foreground">
{seoData.snippet[`title_${additionalLangCode}`]?.length || 0}/100
</span>
</Label>
<div className="flex gap-2">
<Input
value={seoData.snippet[`title_${additionalLangCode}`] || seoData.snippet.title_en || ''}
onChange={(e) => updateSeoField(`snippet.title_${additionalLangCode}`, e.target.value)}
maxLength={100}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopy(seoData.snippet[`title_${additionalLangCode}`] || seoData.snippet.title_en || '', 'title_en')}
>
{copied === 'title_en' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
{/* Description */}
<div className="space-y-2">
<Label className="flex items-center justify-between">
Description
<span className="text-xs text-muted-foreground">
{seoData.snippet[`description_${additionalLangCode}`]?.length || seoData.snippet.description_en?.length || 0}/5000
</span>
</Label>
<div className="flex gap-2">
<Textarea
value={seoData.snippet[`description_${additionalLangCode}`] || seoData.snippet.description_en || ''}
onChange={(e) => updateSeoField(`snippet.description_${additionalLangCode}`, e.target.value)}
rows={10}
className="font-mono text-sm"
maxLength={5000}
/>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => handleCopy(seoData.snippet[`description_${additionalLangCode}`] || seoData.snippet.description_en || '', 'desc_en')}
>
{copied === 'desc_en' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
{/* Tags */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Tag className="w-4 h-4" />
Tags
</Label>
<div className="flex flex-wrap gap-1.5">
{(seoData.snippet[`tags_${additionalLangCode}`] || seoData.snippet.tags_en)?.map((tag: string, i: number) => (
<Badge key={i} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
{/* Pinned Comment */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Pinned Comment
</Label>
<Textarea
value={seoData[`pinned_comment_${additionalLangCode}`] || seoData.pinned_comment_en || ''}
onChange={(e) => updateSeoField(`pinned_comment_${additionalLangCode}`, e.target.value)}
rows={4}
/>
</div>
</TabsContent>
</Tabs>
{/* Chapters */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Clock className="w-4 h-4" />
()
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{seoData.chapters?.map((chapter, i) => (
<div key={i} className="flex items-center gap-3 text-sm">
<span className="font-mono text-primary w-12">{chapter.time}</span>
<span className="flex-1">{chapter.title_ko}</span>
<span className="text-muted-foreground">
{chapter[`title_${additionalLangCode}`] || chapter.title_en}
</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* Thumbnail Text */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<ImageIcon className="w-4 h-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<p className="font-bold">{seoData.thumbnail_text?.short_ko}</p>
<p className="text-sm text-muted-foreground">
{seoData.thumbnail_text?.[`short_${additionalLangCode}`] || seoData.thumbnail_text?.short_en}
</p>
</div>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<p className="font-medium">{seoData.thumbnail_text?.sub_ko}</p>
<p className="text-sm text-muted-foreground">
{seoData.thumbnail_text?.[`sub_${additionalLangCode}`] || seoData.thumbnail_text?.sub_en}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
) : (
<div className="flex flex-col items-center justify-center py-20">
<Sparkles className="w-12 h-12 text-primary mb-4" />
<p className="text-lg font-medium">SEO </p>
<Button className="mt-4" onClick={generateSEO}>
<Sparkles className="w-4 h-4 mr-2" />
AI
</Button>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t bg-muted/30">
<Button variant="outline" onClick={generateSEO} disabled={isGenerating}>
<RefreshCw className={`w-4 h-4 mr-2 ${isGenerating ? 'animate-spin' : ''}`} />
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
</Button>
<Button
onClick={handleUpload}
disabled={!seoData || isLoading}
className="bg-red-600 hover:bg-red-700"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
YouTube
</>
)}
</Button>
</div>
</div>
</div>
</div>
);
};
export default YouTubeSEOPreview;

View File

@ -0,0 +1,206 @@
import React from 'react';
import {
LayoutDashboard,
Plus,
FolderOpen,
Image,
User,
Moon,
Sun,
LogOut,
Sparkles,
CreditCard,
Settings,
Building2,
PartyPopper
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import { useLanguage } from '../../contexts/LanguageContext';
import { useAuth } from '../../contexts/AuthContext';
import { useUserLevel, LEVEL_INFO } from '../../contexts/UserLevelContext';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Switch } from '../ui/switch';
import { Progress } from '../ui/progress';
import { Badge } from '../ui/badge';
export type ViewType = 'dashboard' | 'new-project' | 'library' | 'assets' | 'pensions' | 'festivals' | 'account' | 'settings';
interface SidebarProps {
currentView: ViewType;
onViewChange: (view: ViewType) => void;
libraryCount?: number;
}
interface NavItemProps {
icon: React.ReactNode;
label: string;
active?: boolean;
onClick?: () => void;
}
const NavItem: React.FC<NavItemProps> = ({ icon, label, active, onClick }) => {
return (
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200",
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<span className={cn("w-5 h-5", active && "text-primary-foreground")}>
{icon}
</span>
<span>{label}</span>
</button>
);
};
const Sidebar: React.FC<SidebarProps> = ({
currentView,
onViewChange,
libraryCount = 0
}) => {
const { theme, toggleTheme } = useTheme();
const { t } = useLanguage();
const { user, logout } = useAuth();
const { level, features } = useUserLevel();
const levelInfo = LEVEL_INFO[level];
// Mock credits - replace with actual user data
const credits = 850;
const maxCredits = 1000;
const creditPercentage = (credits / maxCredits) * 100;
return (
<aside className="w-64 h-screen bg-card border-r border-border flex flex-col shrink-0">
{/* Logo */}
<div className="p-5">
<div className="flex items-center gap-3">
<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" />
</div>
<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>
{/* Navigation */}
<nav className="flex-1 px-3 py-2 space-y-1">
{/* 항상 표시: 대시보드 */}
<NavItem
icon={<LayoutDashboard className="w-5 h-5" />}
label={user?.name || user?.username || t('dashWelcome').replace('!', '')}
active={currentView === 'dashboard'}
onClick={() => onViewChange('dashboard')}
/>
{/* 항상 표시: 새 영상 만들기 */}
<NavItem
icon={<Plus className="w-5 h-5" />}
label={t('dashNewProject')}
active={currentView === 'new-project'}
onClick={() => onViewChange('new-project')}
/>
{/* 항상 표시: 보관함 */}
<NavItem
icon={<FolderOpen className="w-5 h-5" />}
label={t('libTitle')}
active={currentView === 'library'}
onClick={() => onViewChange('library')}
/>
{/* 중급/프로만: 에셋 라이브러리 */}
{features.showAssetLibrary && (
<NavItem
icon={<Image className="w-5 h-5" />}
label={t('dashAssetManage')}
active={currentView === 'assets'}
onClick={() => onViewChange('assets')}
/>
)}
{/* 중급/프로만: 펜션 관리 */}
{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
icon={<User className="w-5 h-5" />}
label={t('accountTitle')}
active={currentView === 'account'}
onClick={() => onViewChange('account')}
/>
{/* 중급/프로만: 비즈니스 설정 */}
{features.showAdvancedSettings && (
<NavItem
icon={<Settings className="w-5 h-5" />}
label={t('settingsTitle')}
active={currentView === 'settings'}
onClick={() => onViewChange('settings')}
/>
)}
</nav>
{/* Bottom Section */}
<div className="p-4 space-y-4">
{/* Dark Mode Toggle */}
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{theme === 'dark' ? (
<Moon className="w-4 h-4" />
) : (
<Sun className="w-4 h-4" />
)}
<span>{theme === 'dark' ? t('accountThemeDark') : t('accountThemeLight')}</span>
</div>
<Switch
checked={theme === 'dark'}
onCheckedChange={toggleTheme}
/>
</div>
{/* Credits */}
<div className="bg-muted/50 rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">CREDITS</span>
<span className="text-sm font-bold text-foreground">{credits} / {maxCredits}</span>
</div>
<Progress value={creditPercentage} className="h-2" />
<Button variant="outline" className="w-full" size="sm">
<CreditCard className="w-4 h-4 mr-2" />
{t('accountUpgrade')}
</Button>
</div>
{/* Logout */}
<button
onClick={logout}
className="w-full flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<LogOut className="w-4 h-4" />
<span>{t('authLogout')}</span>
</button>
</div>
</aside>
);
};
export default Sidebar;

View File

@ -0,0 +1,46 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Badge } from '../ui/badge';
interface SidebarItemProps {
icon: LucideIcon;
label: string;
active?: boolean;
badge?: number | string;
onClick?: () => void;
}
const SidebarItem: React.FC<SidebarItemProps> = ({
icon: Icon,
label,
active = false,
badge,
onClick
}) => {
return (
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200",
"hover:bg-accent/10",
active
? "bg-primary/10 text-primary border border-primary/20"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className={cn("w-5 h-5", active && "text-primary")} />
<span className="flex-1 text-left">{label}</span>
{badge !== undefined && (
<Badge
variant={active ? "default" : "secondary"}
className="text-xs px-1.5 py-0.5 min-w-[20px] justify-center"
>
{badge}
</Badge>
)}
</button>
);
};
export default SidebarItem;

View File

@ -0,0 +1,80 @@
import React from 'react';
import { ChevronDown, User } from 'lucide-react';
import { useLanguage } from '../../contexts/LanguageContext';
import { useAuth } from '../../contexts/AuthContext';
import { Language } from '../../../types';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
interface TopHeaderProps {
breadcrumb?: string;
title?: string;
}
const LANGUAGES: { value: Language; label: string; flag: string }[] = [
{ value: 'KO', label: '한국어', flag: 'KR' },
{ value: 'EN', label: 'English', flag: 'EN' },
{ value: 'JA', label: '日本語', flag: 'JP' },
{ value: 'ZH', label: '中文', flag: 'CN' },
{ value: 'TH', label: 'ภาษาไทย', flag: 'TH' },
{ value: 'VI', label: 'Tiếng Việt', flag: 'VN' },
];
const TopHeader: React.FC<TopHeaderProps> = ({ breadcrumb, title }) => {
const { language, setLanguage } = useLanguage();
const { user } = useAuth();
const currentLang = LANGUAGES.find(l => l.value === language) || LANGUAGES[0];
return (
<header className="h-16 border-b border-border bg-card/50 backdrop-blur-sm flex items-center justify-between px-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm">
{breadcrumb && (
<>
<span className="text-muted-foreground">{breadcrumb}</span>
<span className="text-muted-foreground">/</span>
</>
)}
{title && (
<span className="font-medium text-foreground">{title}</span>
)}
</div>
{/* Right Section */}
<div className="flex items-center gap-4">
{/* Language Selector */}
<Select value={language} onValueChange={(v) => setLanguage(v as Language)}>
<SelectTrigger className="w-[130px] h-9 bg-muted/50 border-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">{currentLang.flag}</span>
<SelectValue>{currentLang.label}</SelectValue>
</div>
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">{lang.flag}</span>
<span>{lang.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* User Avatar */}
<div className="w-9 h-9 rounded-full bg-primary/20 flex items-center justify-center text-sm font-bold text-primary">
{user?.name?.charAt(0).toUpperCase() || 'U'}
</div>
</div>
</header>
);
};
export default TopHeader;

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,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/src/lib/utils"
import { buttonVariants } from "@/src/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/src/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/src/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/src/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/src/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/src/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("grid place-content-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/src/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/src/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/src/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/src/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

Some files were not shown because too many files have changed in this diff Show More