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