first commit
|
|
@ -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"]
|
||||
|
|
@ -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_* (비용 분석)
|
||||
|
|
@ -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 (사용자 토큰, 자동생성)
|
||||
|
|
@ -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/
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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부터 순차적으로 진행할까요?
|
||||
|
|
@ -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**
|
||||
|
|
@ -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 파일을 서버로 전송하세요."
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
39498
|
||||
|
|
@ -0,0 +1 @@
|
|||
39458
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
After Width: | Height: | Size: 193 KiB |
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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 # 환경변수 로드
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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' });
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
};
|
||||
|
|
@ -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 분석에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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 || "인스타그램 프로필 정보를 가져오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -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 || "네이버 플레이스 정보를 가져오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -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 || "음악 생성 중 알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -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 ""
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||