version19
parent
d820394ccc
commit
07a3a1093b
|
|
@ -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 (사용자 토큰, 자동생성)
|
||||||
|
|
@ -31,6 +31,19 @@ server/database.sqlite
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.production.example
|
||||||
|
|
||||||
|
# Sensitive files (API keys, credentials)
|
||||||
|
server/google-billing-key.json
|
||||||
|
server/client_secret.json
|
||||||
|
server/youtube-tokens.json
|
||||||
|
**/tokens.json
|
||||||
|
|
||||||
|
# Analysis files
|
||||||
|
*_analysis_*.txt
|
||||||
|
|
||||||
|
# Windows Zone.Identifier files
|
||||||
|
*:Zone.Identifier
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { GeneratedAssets } from '../types';
|
import { GeneratedAssets } from '../types';
|
||||||
import { Play, Pause, RefreshCw, Download, Image as ImageIcon, Video as VideoIcon, Music, Mic, Loader2, Film, Share2, Youtube, ArrowLeft, Volume2, VolumeX } from 'lucide-react';
|
import { Play, Pause, RefreshCw, Download, Image as ImageIcon, Video as VideoIcon, Music, Mic, Loader2, Film, Share2, Youtube, Instagram, ArrowLeft, Volume2, VolumeX } from 'lucide-react';
|
||||||
import { mergeVideoAndAudio } from '../services/ffmpegService';
|
import { mergeVideoAndAudio } from '../services/ffmpegService';
|
||||||
import ShareModal from './ShareModal';
|
import ShareModal from './ShareModal';
|
||||||
import SlideshowBackground from './SlideshowBackground';
|
import SlideshowBackground from './SlideshowBackground';
|
||||||
|
|
@ -55,6 +55,16 @@ const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay =
|
||||||
const [showShareModal, setShowShareModal] = useState(false);
|
const [showShareModal, setShowShareModal] = useState(false);
|
||||||
const [showSEOPreview, setShowSEOPreview] = useState(false);
|
const [showSEOPreview, setShowSEOPreview] = useState(false);
|
||||||
|
|
||||||
|
// Instagram 업로드 상태
|
||||||
|
const [isUploadingInstagram, setIsUploadingInstagram] = useState(false);
|
||||||
|
const [instagramUploadStatus, setInstagramUploadStatus] = useState('');
|
||||||
|
const [instagramUrl, setInstagramUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 렌더링 큐 상태
|
||||||
|
const [currentJobId, setCurrentJobId] = useState<string | null>(null);
|
||||||
|
const [renderStatus, setRenderStatus] = useState<'idle' | 'submitting' | 'processing' | 'completed' | 'failed'>('idle');
|
||||||
|
const [renderMessage, setRenderMessage] = useState('');
|
||||||
|
|
||||||
// AutoPlay 로직
|
// AutoPlay 로직
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
|
|
@ -199,24 +209,14 @@ const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay =
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isPlaying, assets.adCopy.length]);
|
}, [isPlaying, assets.adCopy.length]);
|
||||||
|
|
||||||
const handleServerDownload = async () => {
|
// URL을 Base64로 변환하는 유틸리티 함수
|
||||||
if (isServerDownloading) return;
|
|
||||||
setIsServerDownloading(true);
|
|
||||||
setDownloadProgress(10);
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 300000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const urlToBase64 = async (url: string): Promise<string | undefined> => {
|
const urlToBase64 = async (url: string): Promise<string | undefined> => {
|
||||||
if (!url) return undefined;
|
if (!url) return undefined;
|
||||||
try {
|
try {
|
||||||
// blob URL이든 외부 URL이든 모두 처리
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
if (url.startsWith('blob:')) {
|
if (url.startsWith('blob:')) {
|
||||||
response = await fetch(url);
|
response = await fetch(url);
|
||||||
} else if (url.startsWith('http')) {
|
} else if (url.startsWith('http')) {
|
||||||
// 외부 URL은 프록시를 통해 가져옴 (CORS 우회)
|
|
||||||
response = await fetch(`/api/proxy/audio?url=${encodeURIComponent(url)}`);
|
response = await fetch(`/api/proxy/audio?url=${encodeURIComponent(url)}`);
|
||||||
} else {
|
} else {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -232,16 +232,95 @@ const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay =
|
||||||
reader.readAsDataURL(blob);
|
reader.readAsDataURL(blob);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Audio URL to Base64 failed:', e);
|
console.error('URL to Base64 failed:', e);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
setDownloadProgress(20);
|
// 렌더링 작업 상태 폴링
|
||||||
|
const pollJobStatus = async (jobId: string) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
let pollCount = 0;
|
||||||
|
const maxPolls = 180; // 최대 3분 (1초 간격)
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (pollCount >= maxPolls) {
|
||||||
|
setRenderStatus('failed');
|
||||||
|
setRenderMessage('렌더링 시간 초과');
|
||||||
|
setIsServerDownloading(false);
|
||||||
|
setIsRendering(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/render/status/${jobId}`, {
|
||||||
|
headers: { 'Authorization': token ? `Bearer ${token}` : '' }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { job } = data;
|
||||||
|
setDownloadProgress(job.progress || 0);
|
||||||
|
|
||||||
|
if (job.status === 'completed') {
|
||||||
|
setRenderStatus('completed');
|
||||||
|
setRenderMessage('렌더링 완료!');
|
||||||
|
setLastProjectFolder(job.downloadUrl?.split('/')[2] || null);
|
||||||
|
setIsServerDownloading(false);
|
||||||
|
setIsRendering(false);
|
||||||
|
|
||||||
|
// 자동 다운로드
|
||||||
|
if (job.downloadUrl) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = job.downloadUrl;
|
||||||
|
a.download = `CastAD_${assets.businessName}_Final.mp4`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.status === 'failed') {
|
||||||
|
setRenderStatus('failed');
|
||||||
|
setRenderMessage(job.error_message || '렌더링 실패');
|
||||||
|
setIsServerDownloading(false);
|
||||||
|
setIsRendering(false);
|
||||||
|
alert(`렌더링 실패: ${job.error_message || '알 수 없는 오류'}\n크레딧이 환불되었습니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 계속 폴링
|
||||||
|
pollCount++;
|
||||||
|
setTimeout(poll, 1000);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('폴링 오류:', error);
|
||||||
|
pollCount++;
|
||||||
|
setTimeout(poll, 2000); // 오류 시 2초 후 재시도
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
poll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerDownload = async () => {
|
||||||
|
if (isServerDownloading) return;
|
||||||
|
setIsServerDownloading(true);
|
||||||
|
setRenderStatus('submitting');
|
||||||
|
setRenderMessage('렌더링 요청 중...');
|
||||||
|
setDownloadProgress(5);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 데이터 준비
|
||||||
|
setDownloadProgress(10);
|
||||||
const posterBase64 = await urlToBase64(assets.posterUrl);
|
const posterBase64 = await urlToBase64(assets.posterUrl);
|
||||||
const audioBase64 = await urlToBase64(assets.audioUrl);
|
const audioBase64 = await urlToBase64(assets.audioUrl);
|
||||||
|
|
||||||
setDownloadProgress(30);
|
setDownloadProgress(15);
|
||||||
let imagesBase64: string[] = [];
|
let imagesBase64: string[] = [];
|
||||||
if (assets.images && assets.images.length > 0) {
|
if (assets.images && assets.images.length > 0) {
|
||||||
imagesBase64 = await Promise.all(
|
imagesBase64 = await Promise.all(
|
||||||
|
|
@ -257,64 +336,72 @@ const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay =
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setDownloadProgress(40);
|
setDownloadProgress(20);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...assets,
|
|
||||||
historyId: assets.id,
|
|
||||||
posterBase64,
|
posterBase64,
|
||||||
audioBase64,
|
audioBase64,
|
||||||
imagesBase64
|
imagesBase64,
|
||||||
|
adCopy: assets.adCopy,
|
||||||
|
textEffect: assets.textEffect || 'effect-fade',
|
||||||
|
businessName: assets.businessName,
|
||||||
|
aspectRatio: assets.aspectRatio || '9:16',
|
||||||
|
pensionId: assets.pensionId
|
||||||
};
|
};
|
||||||
|
|
||||||
setIsRendering(true);
|
|
||||||
setDownloadProgress(50);
|
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await fetch('/render', {
|
|
||||||
|
// 렌더링 작업 시작 요청
|
||||||
|
const response = await fetch('/api/render/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': token ? `Bearer ${token}` : ''
|
'Authorization': token ? `Bearer ${token}` : ''
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload)
|
||||||
signal: controller.signal
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setDownloadProgress(80);
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok || !data.success) {
|
||||||
const err = await response.text();
|
// 이미 진행 중인 작업이 있는 경우
|
||||||
throw new Error(`서버 오류: ${err}`);
|
if (data.errorCode === 'RENDER_IN_PROGRESS') {
|
||||||
|
setCurrentJobId(data.existingJobId);
|
||||||
|
setRenderStatus('processing');
|
||||||
|
setRenderMessage(`기존 작업 진행 중 (${data.existingJobProgress || 0}%)`);
|
||||||
|
setIsRendering(true);
|
||||||
|
setDownloadProgress(data.existingJobProgress || 0);
|
||||||
|
|
||||||
|
// 기존 작업 상태 폴링 시작
|
||||||
|
pollJobStatus(data.existingJobId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(data.error || '렌더링 요청 실패');
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderNameHeader = response.headers.get('X-Project-Folder');
|
// 작업 ID 저장 및 폴링 시작
|
||||||
if (folderNameHeader) {
|
setCurrentJobId(data.jobId);
|
||||||
setLastProjectFolder(decodeURIComponent(folderNameHeader));
|
setRenderStatus('processing');
|
||||||
}
|
setRenderMessage(`렌더링 중... (크레딧 ${data.creditsCharged} 차감)`);
|
||||||
|
setIsRendering(true);
|
||||||
|
setDownloadProgress(25);
|
||||||
|
|
||||||
setDownloadProgress(90);
|
// 상태 폴링 시작
|
||||||
const blob = await response.blob();
|
pollJobStatus(data.jobId);
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `CastAD_${assets.businessName}_Final.mp4`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
a.remove();
|
|
||||||
|
|
||||||
setDownloadProgress(100);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
if (e.name === 'AbortError') {
|
setRenderStatus('failed');
|
||||||
alert("영상 생성 시간 초과 (5분). 서버 부하가 높거나 네트워크 문제일 수 있습니다.");
|
setRenderMessage(e.message || '렌더링 요청 실패');
|
||||||
} else {
|
|
||||||
alert("영상 생성 실패: 서버가 실행 중인지 확인해주세요.");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
setIsServerDownloading(false);
|
setIsServerDownloading(false);
|
||||||
setIsRendering(false);
|
setIsRendering(false);
|
||||||
setTimeout(() => setDownloadProgress(0), 2000);
|
setDownloadProgress(0);
|
||||||
|
|
||||||
|
if (e.message?.includes('크레딧')) {
|
||||||
|
alert(e.message);
|
||||||
|
} else {
|
||||||
|
alert(`렌더링 요청 실패: ${e.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -356,12 +443,14 @@ const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay =
|
||||||
// 비디오 경로 생성
|
// 비디오 경로 생성
|
||||||
const videoPath = `downloads/${lastProjectFolder}/final.mp4`;
|
const videoPath = `downloads/${lastProjectFolder}/final.mp4`;
|
||||||
|
|
||||||
let response;
|
// YouTube 연결 확인
|
||||||
|
if (!connData.connected) {
|
||||||
|
throw new Error("YouTube 계정이 연결되지 않았습니다. 설정에서 YouTube 계정을 먼저 연결해주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
if (connData.connected) {
|
// 사용자 채널에 업로드
|
||||||
// 사용자 채널에 업로드 (새 API)
|
|
||||||
setUploadStatus("내 채널에 업로드 중...");
|
setUploadStatus("내 채널에 업로드 중...");
|
||||||
response = await fetch('/api/youtube/my-upload', {
|
const response = await fetch('/api/youtube/my-upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -374,22 +463,6 @@ const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay =
|
||||||
categoryId
|
categoryId
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// 레거시 업로드 (시스템 채널)
|
|
||||||
setUploadStatus("시스템 채널에 업로드 중...");
|
|
||||||
response = await fetch('/api/youtube/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': token ? `Bearer ${token}` : ''
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
videoPath,
|
|
||||||
seoData: { title, description, tags },
|
|
||||||
categoryId
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errData = await response.json();
|
const errData = await response.json();
|
||||||
|
|
@ -397,8 +470,8 @@ const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay =
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setYoutubeUrl(data.youtubeUrl);
|
setYoutubeUrl(data.youtubeUrl || data.url);
|
||||||
setUploadStatus(connData.connected ? "내 채널에 업로드 완료!" : "업로드 완료!");
|
setUploadStatus("내 채널에 업로드 완료!");
|
||||||
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
@ -409,6 +482,67 @@ const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay =
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Instagram 업로드 핸들러
|
||||||
|
const handleInstagramUpload = async () => {
|
||||||
|
if (!lastProjectFolder) {
|
||||||
|
alert("먼저 영상을 생성(다운로드)해야 업로드할 수 있습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploadingInstagram(true);
|
||||||
|
setInstagramUploadStatus("Instagram 연결 확인 중...");
|
||||||
|
setInstagramUrl(null);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Instagram 연결 상태 확인
|
||||||
|
const statusRes = await fetch('/api/instagram/status', {
|
||||||
|
headers: { 'Authorization': token ? `Bearer ${token}` : '' }
|
||||||
|
});
|
||||||
|
const statusData = await statusRes.json();
|
||||||
|
|
||||||
|
if (!statusData.connected) {
|
||||||
|
throw new Error("Instagram 계정이 연결되지 않았습니다. 설정에서 Instagram 계정을 먼저 연결해주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 캡션 생성 (광고 카피)
|
||||||
|
const adCopyText = assets.adCopy?.join('\n') || assets.businessName || '';
|
||||||
|
const hashtags = `#${assets.businessName?.replace(/\s+/g, '')} #펜션 #숙소 #여행 #힐링 #휴가 #AI마케팅 #CaStAD`;
|
||||||
|
|
||||||
|
setInstagramUploadStatus("Instagram Reels 업로드 중...");
|
||||||
|
|
||||||
|
const response = await fetch('/api/instagram/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': token ? `Bearer ${token}` : ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
history_id: assets.id,
|
||||||
|
caption: adCopyText,
|
||||||
|
hashtags: hashtags
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json();
|
||||||
|
throw new Error(errData.error || "Instagram 업로드 실패");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setInstagramUrl(data.mediaUrl || data.url || 'uploaded');
|
||||||
|
setInstagramUploadStatus("Instagram Reels 업로드 완료!");
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
setInstagramUploadStatus(e.message || "업로드 실패");
|
||||||
|
alert(`Instagram 업로드 실패: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsUploadingInstagram(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleMergeDownload = async () => {
|
const handleMergeDownload = async () => {
|
||||||
if (isMerging) return;
|
if (isMerging) return;
|
||||||
|
|
||||||
|
|
@ -896,17 +1030,76 @@ const ResultPlayer: React.FC<ResultPlayerProps> = ({ assets, onReset, autoPlay =
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Instagram 업로드 */}
|
||||||
|
{!instagramUrl ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleInstagramUpload}
|
||||||
|
disabled={isUploadingInstagram || !lastProjectFolder}
|
||||||
|
className={cn(
|
||||||
|
"gap-2",
|
||||||
|
lastProjectFolder && "bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 hover:from-purple-600 hover:via-pink-600 hover:to-orange-600 text-white border-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isUploadingInstagram ? <Loader2 className="w-4 h-4 animate-spin" /> : <Instagram className="w-4 h-4" />}
|
||||||
|
Instagram
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{!lastProjectFolder
|
||||||
|
? (t('textStyle') === '자막 스타일' ? '먼저 영상을 저장하세요' : 'Save video first')
|
||||||
|
: (t('textStyle') === '자막 스타일' ? 'Instagram Reels 업로드' : 'Upload to Instagram Reels')
|
||||||
|
}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 gap-2"
|
||||||
|
>
|
||||||
|
<Instagram className="w-4 h-4" />
|
||||||
|
{t('textStyle') === '자막 스타일' ? '완료' : 'Done'}
|
||||||
|
</Button>
|
||||||
|
<Badge variant="outline" className="text-green-500 border-green-500/50">
|
||||||
|
{t('textStyle') === '자막 스타일' ? '업로드 완료' : 'Uploaded'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 다운로드 진행률 */}
|
{/* 렌더링 진행률 및 상태 */}
|
||||||
{downloadProgress > 0 && downloadProgress < 100 && (
|
{(downloadProgress > 0 || renderStatus === 'processing') && (
|
||||||
<div className="w-full pt-2 border-t border-border/50">
|
<div className="w-full pt-2 border-t border-border/50">
|
||||||
<Progress value={downloadProgress} className="h-2" />
|
<Progress value={downloadProgress} className="h-2" />
|
||||||
<p className="text-xs text-muted-foreground text-center mt-1">
|
<div className="flex items-center justify-center gap-2 mt-1">
|
||||||
{t('textStyle') === '자막 스타일' ? '렌더링 중...' : 'Rendering...'} {downloadProgress}%
|
{renderStatus === 'processing' && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{renderMessage || (t('textStyle') === '자막 스타일' ? '렌더링 중...' : 'Rendering...')} {downloadProgress}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{renderStatus === 'processing' && (
|
||||||
|
<p className="text-xs text-muted-foreground/70 text-center mt-1">
|
||||||
|
{t('textStyle') === '자막 스타일'
|
||||||
|
? '페이지를 나가도 렌더링은 계속됩니다'
|
||||||
|
: 'Rendering continues even if you leave'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Instagram 업로드 상태 */}
|
||||||
|
{isUploadingInstagram && instagramUploadStatus && (
|
||||||
|
<div className="w-full pt-2 border-t border-border/50">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-pink-500" />
|
||||||
|
<p className="text-xs text-muted-foreground">{instagramUploadStatus}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,156 +1,175 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# CaStAD 배포 스크립트
|
||||||
|
# 로컬에서 castad1.ktenterprise.net으로 배포
|
||||||
|
#
|
||||||
|
|
||||||
# 색상 정의
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 설정
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
SERVER="castad1.ktenterprise.net"
|
||||||
|
SERVER_USER="castad" # 서버 사용자
|
||||||
|
REMOTE_PATH="/home/${SERVER_USER}/castad" # 서버 경로
|
||||||
|
LOCAL_PATH="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# 색상
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
BLUE='\033[0;34m'
|
CYAN='\033[0;36m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
NC='\033[0m' # No Color
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
echo -e "${BLUE}=== BizVibe 배포 자동화 스크립트 ===${NC}"
|
log() { echo -e "${GREEN}[Deploy]${NC} $1"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||||
# 1. 현재 경로 확인
|
info() { echo -e "${CYAN}[INFO]${NC} $1"; }
|
||||||
PROJECT_ROOT=$(pwd)
|
|
||||||
DIST_PATH="$PROJECT_ROOT/dist"
|
|
||||||
echo -e "${GREEN}[1] 현재 프로젝트 경로:${NC} $PROJECT_ROOT"
|
|
||||||
|
|
||||||
# 2. 시스템 의존성 설치 (Puppeteer/Chromium 용)
|
|
||||||
echo -e "${GREEN}[1.5] Installing Puppeteer system dependencies...${NC}"
|
|
||||||
if [ -x "$(command -v apt-get)" ]; then
|
|
||||||
sudo apt-get update && sudo apt-get install -y \
|
|
||||||
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 \
|
|
||||||
fonts-noto-cjk
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 빌드
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
do_build() {
|
||||||
|
log "프론트엔드 빌드 중..."
|
||||||
|
cd "$LOCAL_PATH"
|
||||||
|
npm run build
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
echo -e "${RED}❌ System dependency installation failed!${NC}"
|
error "빌드 실패!"
|
||||||
# 의존성 설치 실패해도 일단 진행 (이미 설치된 경우 등 고려)
|
|
||||||
else
|
|
||||||
echo -e "${GREEN}✅ System dependencies installed successfully.${NC}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}⚠️ 'apt-get' not found. Skipping system dependency installation. Ensure dependencies are installed manually.${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. 프로젝트 빌드
|
|
||||||
echo -e "${GREEN}[2] Building project...${NC}"
|
|
||||||
npm run build:all
|
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo -e "${RED}❌ 빌드 실패! 오류를 확인해주세요.${NC}"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo -e "${GREEN}✅ 빌드 완료!${NC}"
|
info "빌드 완료"
|
||||||
|
|
||||||
# 3. Nginx 설정 파일 생성 (경로 자동 적용)
|
|
||||||
echo -e "${GREEN}[3] Nginx 설정 파일 생성 중...${NC}"
|
|
||||||
|
|
||||||
NGINX_CONF="nginx_bizvibe.conf"
|
|
||||||
DOMAIN_NAME="bizvibe.ktenterprise.net" # 기본 도메인 (필요 시 수정)
|
|
||||||
|
|
||||||
cat > $NGINX_CONF <<EOF
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name $DOMAIN_NAME;
|
|
||||||
|
|
||||||
# 1. 프론트엔드 (React 빌드 결과물)
|
|
||||||
location / {
|
|
||||||
root $DIST_PATH;
|
|
||||||
index index.html;
|
|
||||||
try_files \$uri \$uri/ /index.html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 2. 백엔드 API 프록시
|
# ═══════════════════════════════════════════════════════════════
|
||||||
location /api {
|
# 서버로 파일 전송
|
||||||
proxy_pass http://127.0.0.1:3001;
|
# ═══════════════════════════════════════════════════════════════
|
||||||
proxy_http_version 1.1;
|
do_upload() {
|
||||||
proxy_set_header Upgrade \$http_upgrade;
|
log "서버로 파일 전송 중..."
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host \$host;
|
|
||||||
proxy_cache_bypass \$http_upgrade;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3. 영상 생성 및 다운로드 프록시
|
# 제외할 파일/폴더
|
||||||
location /render {
|
EXCLUDES="--exclude=node_modules --exclude=.git --exclude=*.log --exclude=pids --exclude=logs --exclude=server/database.sqlite --exclude=server/downloads --exclude=server/temp --exclude=.env"
|
||||||
proxy_pass http://127.0.0.1:3001;
|
|
||||||
proxy_set_header Host \$host;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /downloads {
|
# rsync로 동기화
|
||||||
proxy_pass http://127.0.0.1:3001;
|
rsync -avz --progress $EXCLUDES \
|
||||||
proxy_set_header Host \$host;
|
"$LOCAL_PATH/" \
|
||||||
}
|
"${SERVER_USER}@${SERVER}:${REMOTE_PATH}/"
|
||||||
|
|
||||||
location /temp {
|
if [ $? -ne 0 ]; then
|
||||||
proxy_pass http://127.0.0.1:3001;
|
error "파일 전송 실패!"
|
||||||
proxy_set_header Host \$host;
|
exit 1
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo -e "${GREEN}✅ Nginx 설정 파일($NGINX_CONF) 생성 완료! (Root 경로: $DIST_PATH)${NC}"
|
|
||||||
|
|
||||||
# 4. PM2로 백엔드 시작
|
|
||||||
echo -e "${GREEN}[4] PM2로 백엔드 서버 시작...${NC}"
|
|
||||||
|
|
||||||
# PM2 설치 확인 및 설치
|
|
||||||
if ! command -v pm2 &> /dev/null
|
|
||||||
then
|
|
||||||
echo -e "${YELLOW}PM2가 설치되어 있지 않습니다. 설치를 시도합니다...${NC}"
|
|
||||||
npm install -g pm2
|
|
||||||
fi
|
fi
|
||||||
|
info "파일 전송 완료"
|
||||||
|
}
|
||||||
|
|
||||||
# 기존 프로세스 재시작 또는 새로 시작
|
# ═══════════════════════════════════════════════════════════════
|
||||||
pm2 restart bizvibe-backend 2>/dev/null || pm2 start server/index.js --name "bizvibe-backend"
|
# 서버에서 설치 및 재시작
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
do_install() {
|
||||||
|
log "서버에서 의존성 설치 중..."
|
||||||
|
|
||||||
pm2 save
|
ssh "${SERVER_USER}@${SERVER}" << 'REMOTE_SCRIPT'
|
||||||
echo -e "${GREEN}✅ 백엔드 서버 구동 완료!${NC}"
|
cd /home/castad/castad
|
||||||
|
|
||||||
# 5. 최종 안내 (sudo 필요 작업)
|
# npm 의존성 설치
|
||||||
echo -e ""
|
echo "=== 프론트엔드 의존성 설치 ==="
|
||||||
echo -e "${BLUE}=== 🎉 배포 준비 완료! 남은 단계 ===${NC}"
|
npm install --legacy-peer-deps --silent
|
||||||
echo -e "${YELLOW}다음 명령어를 복사하여 실행하면 Nginx 설정이 적용됩니다 (관리자 권한 필요):${NC}"
|
|
||||||
echo -e ""
|
echo "=== 백엔드 의존성 설치 ==="
|
||||||
echo -e "1. 설정 파일 이동:"
|
cd server && npm install --legacy-peer-deps --silent && cd ..
|
||||||
echo -e " ${GREEN}sudo cp $NGINX_CONF /etc/nginx/sites-available/bizvibe${NC}"
|
|
||||||
echo -e ""
|
echo "=== 완료 ==="
|
||||||
echo -e "2. 사이트 활성화:"
|
REMOTE_SCRIPT
|
||||||
echo -e " ${GREEN}sudo ln -s /etc/nginx/sites-available/bizvibe /etc/nginx/sites-enabled/${NC}"
|
|
||||||
echo -e ""
|
if [ $? -ne 0 ]; then
|
||||||
echo -e "3. Nginx 문법 검사 및 재시작:"
|
error "서버 설치 실패!"
|
||||||
echo -e " ${GREEN}sudo nginx -t && sudo systemctl reload nginx${NC}"
|
exit 1
|
||||||
echo -e ""
|
fi
|
||||||
|
info "서버 설치 완료"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_restart() {
|
||||||
|
log "서버 재시작 중..."
|
||||||
|
|
||||||
|
ssh "${SERVER_USER}@${SERVER}" << 'REMOTE_SCRIPT'
|
||||||
|
cd /home/castad/castad
|
||||||
|
./startserver.sh restart
|
||||||
|
REMOTE_SCRIPT
|
||||||
|
|
||||||
|
info "서버 재시작 완료"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 전체 배포
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
do_full_deploy() {
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}${CYAN}╔════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${BOLD}${CYAN}║ CaStAD 배포 시작 ║${NC}"
|
||||||
|
echo -e "${BOLD}${CYAN}║ Target: ${SERVER} ║${NC}"
|
||||||
|
echo -e "${BOLD}${CYAN}╚════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
do_build
|
||||||
|
do_upload
|
||||||
|
do_install
|
||||||
|
do_restart
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}╔════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${GREEN}║ 배포 완료! ║${NC}"
|
||||||
|
echo -e "${GREEN}║ URL: https://${SERVER} ║${NC}"
|
||||||
|
echo -e "${GREEN}╚════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 도움말
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
show_help() {
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}CaStAD 배포 스크립트${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}사용법:${NC}"
|
||||||
|
echo " ./deploy.sh <command>"
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}명령어:${NC}"
|
||||||
|
echo " deploy - 전체 배포 (빌드 → 업로드 → 설치 → 재시작)"
|
||||||
|
echo " build - 로컬 빌드만"
|
||||||
|
echo " upload - 파일 업로드만"
|
||||||
|
echo " install - 서버 의존성 설치"
|
||||||
|
echo " restart - 서버 재시작만"
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}설정:${NC}"
|
||||||
|
echo " 서버: ${SERVER}"
|
||||||
|
echo " 사용자: ${SERVER_USER}"
|
||||||
|
echo " 경로: ${REMOTE_PATH}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}사전 준비:${NC}"
|
||||||
|
echo " 1. SSH 키 설정: ssh-copy-id ${SERVER_USER}@${SERVER}"
|
||||||
|
echo " 2. 서버에 .env 파일 생성"
|
||||||
|
echo " 3. Nginx 설정 (nginx.castad.conf 참고)"
|
||||||
|
echo " 4. SSL 인증서 설정 (Let's Encrypt)"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 메인
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
case "$1" in
|
||||||
|
deploy)
|
||||||
|
do_full_deploy
|
||||||
|
;;
|
||||||
|
build)
|
||||||
|
do_build
|
||||||
|
;;
|
||||||
|
upload)
|
||||||
|
do_upload
|
||||||
|
;;
|
||||||
|
install)
|
||||||
|
do_install
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
do_restart
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "bizvibe---ai-music-video-generator",
|
"name": "castad-ai-marketing-video-platform",
|
||||||
"version": "0.5.0",
|
"version": "3.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bizvibe---ai-music-video-generator",
|
"name": "castad-ai-marketing-video-platform",
|
||||||
"version": "0.5.0",
|
"version": "3.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffmpeg/core": "0.12.6",
|
"@ffmpeg/core": "0.12.6",
|
||||||
"@ffmpeg/ffmpeg": "0.12.10",
|
"@ffmpeg/ffmpeg": "0.12.10",
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
"dom": "^0.0.3",
|
"dom": "^0.0.3",
|
||||||
"lottie-react": "^2.4.1",
|
"lottie-react": "^2.4.1",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
|
"mammoth": "^1.11.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|
@ -3550,6 +3551,15 @@
|
||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xmldom/xmldom": {
|
||||||
|
"version": "0.8.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||||
|
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
|
@ -3624,6 +3634,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/argparse": {
|
||||||
|
"version": "1.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||||
|
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sprintf-js": "~1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/aria-hidden": {
|
"node_modules/aria-hidden": {
|
||||||
"version": "1.2.6",
|
"version": "1.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||||
|
|
@ -3732,6 +3751,12 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bluebird": {
|
||||||
|
"version": "3.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
||||||
|
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
|
|
@ -4099,6 +4124,12 @@
|
||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -4165,6 +4196,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/dingbat-to-unicode": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/dlv": {
|
"node_modules/dlv": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
|
|
@ -4178,6 +4215,15 @@
|
||||||
"integrity": "sha512-Uzda1zIAXO8JG2fm6IbJcdzBrRaC5Q308HTIjCXCQHh7ZVACJOeQzYYvd99plJ2/HUpZQk9IxNI/Y+QrO6poIQ==",
|
"integrity": "sha512-Uzda1zIAXO8JG2fm6IbJcdzBrRaC5Q308HTIjCXCQHh7ZVACJOeQzYYvd99plJ2/HUpZQk9IxNI/Y+QrO6poIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/duck": {
|
||||||
|
"version": "0.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
|
||||||
|
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
|
||||||
|
"license": "BSD",
|
||||||
|
"dependencies": {
|
||||||
|
"underscore": "^1.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eastasianwidth": {
|
"node_modules/eastasianwidth": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
|
|
@ -4592,6 +4638,18 @@
|
||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
|
|
@ -4663,6 +4721,12 @@
|
||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
|
@ -4736,6 +4800,18 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"license": "(MIT OR GPL-3.0-or-later)",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jwa": {
|
"node_modules/jwa": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
|
@ -4757,6 +4833,15 @@
|
||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
|
|
@ -4777,6 +4862,17 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lop": {
|
||||||
|
"version": "0.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
|
||||||
|
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"duck": "^0.1.12",
|
||||||
|
"option": "~0.2.1",
|
||||||
|
"underscore": "^1.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lottie-react": {
|
"node_modules/lottie-react": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/lottie-react/-/lottie-react-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/lottie-react/-/lottie-react-2.4.1.tgz",
|
||||||
|
|
@ -4815,6 +4911,30 @@
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mammoth": {
|
||||||
|
"version": "1.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz",
|
||||||
|
"integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@xmldom/xmldom": "^0.8.6",
|
||||||
|
"argparse": "~1.0.3",
|
||||||
|
"base64-js": "^1.5.1",
|
||||||
|
"bluebird": "~3.4.0",
|
||||||
|
"dingbat-to-unicode": "^1.0.1",
|
||||||
|
"jszip": "^3.7.1",
|
||||||
|
"lop": "^0.4.2",
|
||||||
|
"path-is-absolute": "^1.0.0",
|
||||||
|
"underscore": "^1.13.1",
|
||||||
|
"xmlbuilder": "^10.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mammoth": "bin/mammoth"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
|
|
@ -5008,12 +5128,33 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/option": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/package-json-from-dist": {
|
"node_modules/package-json-from-dist": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
|
"node_modules/path-is-absolute": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
|
@ -5255,6 +5396,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|
@ -5424,6 +5571,27 @@
|
||||||
"pify": "^2.3.0"
|
"pify": "^2.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readable-stream/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
|
@ -5625,6 +5793,12 @@
|
||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|
@ -5691,6 +5865,27 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sprintf-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
|
|
@ -5988,6 +6183,12 @@
|
||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/underscore": {
|
||||||
|
"version": "1.13.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
|
||||||
|
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
|
@ -6082,7 +6283,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
|
|
@ -6296,6 +6496,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xmlbuilder": {
|
||||||
|
"version": "10.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
||||||
|
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "castad-ai-marketing-video-platform",
|
"name": "castad-ai-marketing-video-platform",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.0.0",
|
"version": "3.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"vite\" \"cd server && node index.js\"",
|
"dev": "concurrently \"vite\" \"cd server && node index.js\"",
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
"dom": "^0.0.3",
|
"dom": "^0.0.3",
|
||||||
"lottie-react": "^2.4.1",
|
"lottie-react": "^2.4.1",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
|
"mammoth": "^1.11.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,337 @@
|
||||||
|
# CaStAD UX 레벨 시스템 구현 계획
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
사용자 경험 레벨에 따라 UI 복잡도를 조절하는 3단계 시스템 구현
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 사용자 레벨 정의
|
||||||
|
|
||||||
|
| 레벨 | 이름 | 대상 | 핵심 철학 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| **Beginner** | 쌩초보 | IT 초보, 빠른 결과 원함 | "알아서 다 해줘" |
|
||||||
|
| **Intermediate** | 중급 | 약간의 커스터마이징 원함 | "몇 가지만 선택할게" |
|
||||||
|
| **Pro** | 프로 | 모든 옵션 제어 원함 | "내가 다 컨트롤" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 레벨별 기능 매트릭스
|
||||||
|
|
||||||
|
### 2.1 회원가입/온보딩
|
||||||
|
|
||||||
|
| 기능 | Beginner | Intermediate | Pro |
|
||||||
|
|------|----------|--------------|-----|
|
||||||
|
| 펜션 정보 입력 | 1개 필수 | 1개 필수 | 선택 |
|
||||||
|
| 지도 URL 자동 수집 | O | O | O |
|
||||||
|
| 사진 업로드 | 최소 3장 | 최소 3장 | 선택 |
|
||||||
|
|
||||||
|
### 2.2 새 프로젝트 생성
|
||||||
|
|
||||||
|
| 옵션 | Beginner | Intermediate | Pro |
|
||||||
|
|------|----------|--------------|-----|
|
||||||
|
| 펜션 선택 | 자동 (기본 펜션) | 선택 가능 | 선택 가능 |
|
||||||
|
| 사진 선택 | 펜션 사진 자동 로드 | 펜션 사진 자동 로드 | 직접 업로드 |
|
||||||
|
| 사진 편집 | 삭제/추가만 | 삭제/추가/AI생성 | 전체 |
|
||||||
|
| **언어** | 한국어 고정 | 선택 가능 | 선택 가능 |
|
||||||
|
| **음악 장르** | AI 자동 선택 | 선택 가능 | 선택 가능 |
|
||||||
|
| **음악 길이** | Auto | 선택 가능 | 선택 가능 |
|
||||||
|
| **오디오 모드** | Song 고정 | 선택 가능 | 선택 가능 |
|
||||||
|
| **영상 스타일** | Video 고정 | 선택 가능 | 선택 가능 |
|
||||||
|
| **텍스트 이펙트** | Auto 랜덤 | 선택 가능 | 선택 가능 |
|
||||||
|
| **전환 효과** | Mix 고정 | 선택 가능 | 선택 가능 |
|
||||||
|
| **영상 비율** | 9:16 고정 | 선택 가능 | 선택 가능 |
|
||||||
|
| **축제 연동** | X | X | O |
|
||||||
|
| **AI 이미지 생성** | X | O | O |
|
||||||
|
| **커스텀 CSS** | X | X | O |
|
||||||
|
|
||||||
|
### 2.3 결과 및 업로드
|
||||||
|
|
||||||
|
| 기능 | Beginner | Intermediate | Pro |
|
||||||
|
|------|----------|--------------|-----|
|
||||||
|
| 미리보기 | O | O | O |
|
||||||
|
| YouTube 업로드 | **자동** | **자동** | 수동/자동 선택 |
|
||||||
|
| SEO 최적화 화면 | X (자동) | X (자동) | O (편집 가능) |
|
||||||
|
| 썸네일 선택 | X (자동) | O | O |
|
||||||
|
| Instagram 업로드 | 자동 | 자동 | 수동/자동 |
|
||||||
|
| TikTok 업로드 | 자동 | 자동 | 수동/자동 |
|
||||||
|
|
||||||
|
### 2.4 사이드바 메뉴
|
||||||
|
|
||||||
|
| 메뉴 | Beginner | Intermediate | Pro |
|
||||||
|
|------|----------|--------------|-----|
|
||||||
|
| 대시보드 | O | O | O |
|
||||||
|
| 새 영상 만들기 | O (간소화) | O | O (전체) |
|
||||||
|
| 보관함 | O | O | O |
|
||||||
|
| 에셋 라이브러리 | X | O | O |
|
||||||
|
| 펜션 관리 | X (1개 고정) | O | O |
|
||||||
|
| 축제 정보 | X | X | O |
|
||||||
|
| 계정 관리 | O (간소화) | O | O |
|
||||||
|
| 비즈니스 설정 | X | O (일부) | O (전체) |
|
||||||
|
|
||||||
|
### 2.5 자동화 기능
|
||||||
|
|
||||||
|
| 기능 | Beginner | Intermediate | Pro |
|
||||||
|
|------|----------|--------------|-----|
|
||||||
|
| 주간 자동 생성 | O (요금제별) | O (요금제별) | O (요금제별) |
|
||||||
|
| 자동 업로드 | 강제 ON | 강제 ON | 선택 가능 |
|
||||||
|
| 스케줄 설정 | X | X | O |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 구현 계획
|
||||||
|
|
||||||
|
### Phase 1: 기반 인프라 (DB + Context)
|
||||||
|
|
||||||
|
#### 3.1 DB 스키마 변경
|
||||||
|
```sql
|
||||||
|
-- users 테이블에 컬럼 추가
|
||||||
|
ALTER TABLE users ADD COLUMN experience_level TEXT DEFAULT 'beginner';
|
||||||
|
-- 값: 'beginner' | 'intermediate' | 'pro'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 UserLevelContext 생성
|
||||||
|
```typescript
|
||||||
|
// src/contexts/UserLevelContext.tsx
|
||||||
|
interface UserLevelContextType {
|
||||||
|
level: 'beginner' | 'intermediate' | 'pro';
|
||||||
|
setLevel: (level: string) => void;
|
||||||
|
features: FeatureFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeatureFlags {
|
||||||
|
showLanguageSelector: boolean;
|
||||||
|
showMusicGenreSelector: boolean;
|
||||||
|
showTextEffectSelector: boolean;
|
||||||
|
showTransitionSelector: boolean;
|
||||||
|
showAspectRatioSelector: boolean;
|
||||||
|
showFestivalIntegration: boolean;
|
||||||
|
showAssetLibrary: boolean;
|
||||||
|
showPensionManagement: boolean;
|
||||||
|
showAdvancedSettings: boolean;
|
||||||
|
showSeoEditor: boolean;
|
||||||
|
autoUpload: boolean;
|
||||||
|
// ... 기타
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: 레벨 선택 UI
|
||||||
|
|
||||||
|
#### 3.3 레벨 선택 컴포넌트
|
||||||
|
- 위치: 계정 설정 또는 온보딩
|
||||||
|
- 3개 카드 형태로 선택
|
||||||
|
- 각 레벨별 아이콘 + 설명
|
||||||
|
- 언제든 변경 가능
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 나에게 맞는 모드를 선택하세요 │
|
||||||
|
├─────────────────┬─────────────────┬─────────────────────────┤
|
||||||
|
│ 🌱 쌩초보 │ 🌿 중급 │ 🌳 프로 │
|
||||||
|
│ │ │ │
|
||||||
|
│ "다 알아서 해줘" │ "조금만 선택" │ "내가 다 컨트롤" │
|
||||||
|
│ │ │ │
|
||||||
|
│ • 원클릭 생성 │ • 장르 선택 │ • 모든 옵션 제어 │
|
||||||
|
│ • 자동 업로드 │ • 언어 선택 │ • 축제 연동 │
|
||||||
|
│ • 간단한 메뉴 │ • 기본 메뉴 │ • 고급 설정 │
|
||||||
|
└─────────────────┴─────────────────┴─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: UI 조건부 렌더링
|
||||||
|
|
||||||
|
#### 3.4 NewProjectView 수정
|
||||||
|
- useUserLevel() 훅으로 현재 레벨 확인
|
||||||
|
- 레벨에 따라 옵션 섹션 표시/숨김
|
||||||
|
- Beginner: 사진만 보여주고 "생성하기" 버튼
|
||||||
|
- Intermediate: 장르, 언어 섹션 추가
|
||||||
|
- Pro: 전체 옵션
|
||||||
|
|
||||||
|
#### 3.5 Sidebar 수정
|
||||||
|
- 레벨에 따라 메뉴 아이템 필터링
|
||||||
|
- Beginner: 대시보드, 새 영상, 보관함, 계정
|
||||||
|
- Intermediate: + 에셋, 펜션 관리, 설정(일부)
|
||||||
|
- Pro: 전체 메뉴
|
||||||
|
|
||||||
|
#### 3.6 SettingsView 수정
|
||||||
|
- 레벨에 따라 설정 항목 표시/숨김
|
||||||
|
- 레벨 변경 UI 추가
|
||||||
|
|
||||||
|
### Phase 4: 자동 업로드 로직
|
||||||
|
|
||||||
|
#### 3.7 영상 생성 완료 후 자동 업로드
|
||||||
|
```javascript
|
||||||
|
// GeneratorPage.tsx의 handleRenderComplete 수정
|
||||||
|
const handleRenderComplete = async (videoPath) => {
|
||||||
|
// 기존 로직...
|
||||||
|
|
||||||
|
// 자동 업로드 (Beginner/Intermediate 또는 Pro with auto_upload=true)
|
||||||
|
if (shouldAutoUpload(userLevel, youtubeSettings)) {
|
||||||
|
await autoUploadToYouTube(videoPath, seoData);
|
||||||
|
await autoUploadToInstagram(videoPath);
|
||||||
|
await autoUploadToTikTok(videoPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.8 SEO 자동 생성 (Beginner/Intermediate)
|
||||||
|
```javascript
|
||||||
|
// 자동 SEO 생성 함수
|
||||||
|
const generateAutoSeo = async (businessInfo, language) => {
|
||||||
|
// Gemini로 자동 생성
|
||||||
|
return {
|
||||||
|
title: `${businessInfo.name} - 힐링 펜션`,
|
||||||
|
description: await generateDescription(businessInfo),
|
||||||
|
tags: await generateTags(businessInfo),
|
||||||
|
hashtags: await generateHashtags(businessInfo)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 5: Beginner 전용 간소화 플로우
|
||||||
|
|
||||||
|
#### 3.9 BeginnerProjectFlow 컴포넌트
|
||||||
|
```
|
||||||
|
단계 1: 펜션 사진 확인
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 📸 이 사진들로 영상을 만들까요? │
|
||||||
|
│ │
|
||||||
|
│ [사진1] [사진2] [사진3] [사진4] │
|
||||||
|
│ │
|
||||||
|
│ [+ 사진 추가] [🗑️ 선택 삭제] │
|
||||||
|
│ │
|
||||||
|
│ [ 🎬 영상 만들기 ] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
|
||||||
|
단계 2: 생성 중 (프로그레스 바)
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 🎵 음악 만드는 중... (30%) │
|
||||||
|
│ ████████░░░░░░░░░░░░ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
|
||||||
|
단계 3: 완료 + 자동 업로드
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ ✅ 영상이 완성되었습니다! │
|
||||||
|
│ │
|
||||||
|
│ [미리보기 영상 플레이어] │
|
||||||
|
│ │
|
||||||
|
│ ✅ YouTube 업로드 완료 │
|
||||||
|
│ ✅ Instagram 업로드 완료 │
|
||||||
|
│ │
|
||||||
|
│ [ 🏠 대시보드로 ] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 파일 변경 목록
|
||||||
|
|
||||||
|
### 신규 파일
|
||||||
|
1. `src/contexts/UserLevelContext.tsx` - 레벨 Context
|
||||||
|
2. `src/components/settings/LevelSelector.tsx` - 레벨 선택 UI
|
||||||
|
3. `src/views/BeginnerProjectView.tsx` - 초보자용 간소화 뷰
|
||||||
|
4. `server/services/autoUploadService.js` - 자동 업로드 서비스
|
||||||
|
|
||||||
|
### 수정 파일
|
||||||
|
1. `server/db.js` - experience_level 컬럼 추가
|
||||||
|
2. `server/index.js` - 레벨 관련 API 추가
|
||||||
|
3. `src/contexts/AuthContext.tsx` - 레벨 정보 포함
|
||||||
|
4. `src/pages/CastADApp.tsx` - UserLevelProvider 추가
|
||||||
|
5. `src/components/layout/Sidebar.tsx` - 메뉴 필터링
|
||||||
|
6. `src/views/NewProjectView.tsx` - 옵션 조건부 렌더링
|
||||||
|
7. `src/views/SettingsView.tsx` - 레벨 변경 UI
|
||||||
|
8. `src/pages/GeneratorPage.tsx` - 자동 업로드 로직
|
||||||
|
9. `types.ts` - UserLevel 타입 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 구현 순서
|
||||||
|
|
||||||
|
1. **DB 스키마 수정** - experience_level 컬럼 추가
|
||||||
|
2. **UserLevelContext 생성** - 레벨 관리 Context
|
||||||
|
3. **API 엔드포인트** - 레벨 조회/수정 API
|
||||||
|
4. **LevelSelector UI** - 레벨 선택 컴포넌트
|
||||||
|
5. **Sidebar 수정** - 메뉴 필터링
|
||||||
|
6. **NewProjectView 수정** - 옵션 조건부 렌더링
|
||||||
|
7. **BeginnerProjectView** - 초보자 전용 플로우
|
||||||
|
8. **자동 업로드 로직** - 생성 완료 후 자동 업로드
|
||||||
|
9. **SettingsView 수정** - 레벨 변경 + 설정 필터링
|
||||||
|
10. **테스트 및 버그 수정**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 예상 소요 시간
|
||||||
|
|
||||||
|
- Phase 1 (기반 인프라): 30분
|
||||||
|
- Phase 2 (레벨 선택 UI): 30분
|
||||||
|
- Phase 3 (조건부 렌더링): 1시간
|
||||||
|
- Phase 4 (자동 업로드): 45분
|
||||||
|
- Phase 5 (Beginner 플로우): 45분
|
||||||
|
- 테스트/수정: 30분
|
||||||
|
|
||||||
|
**총 예상: 약 4시간**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 확정된 사항
|
||||||
|
|
||||||
|
1. **레벨 변경**: 언제든 설정에서 변경 가능
|
||||||
|
2. **온보딩**: 기본 Beginner로 시작, 설정에서 변경
|
||||||
|
3. **주간 자동 생성**: 함께 구현 (cron job)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 추가 구현: 주간 자동 생성 시스템
|
||||||
|
|
||||||
|
### 8.1 DB 스키마 추가
|
||||||
|
```sql
|
||||||
|
-- 자동 생성 설정 테이블
|
||||||
|
CREATE TABLE auto_generation_settings (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER UNIQUE,
|
||||||
|
enabled INTEGER DEFAULT 0,
|
||||||
|
day_of_week INTEGER DEFAULT 1, -- 0=일, 1=월, ..., 6=토
|
||||||
|
time_of_day TEXT DEFAULT '09:00',
|
||||||
|
last_generated_at DATETIME,
|
||||||
|
next_scheduled_at DATETIME,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 자동 생성 작업 큐
|
||||||
|
CREATE TABLE generation_queue (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER,
|
||||||
|
pension_id INTEGER,
|
||||||
|
status TEXT DEFAULT 'pending', -- pending, processing, completed, failed
|
||||||
|
scheduled_at DATETIME,
|
||||||
|
started_at DATETIME,
|
||||||
|
completed_at DATETIME,
|
||||||
|
error_message TEXT,
|
||||||
|
result_video_path TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Cron Job 서비스
|
||||||
|
```javascript
|
||||||
|
// server/services/schedulerService.js
|
||||||
|
const cron = require('node-cron');
|
||||||
|
|
||||||
|
// 매 시간마다 실행 - 예약된 자동 생성 확인
|
||||||
|
cron.schedule('0 * * * *', async () => {
|
||||||
|
const pendingJobs = await getPendingGenerationJobs();
|
||||||
|
for (const job of pendingJobs) {
|
||||||
|
await processAutoGeneration(job);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 자동 생성 플로우
|
||||||
|
1. 사용자 설정에서 자동 생성 ON + 요일/시간 선택
|
||||||
|
2. 스케줄러가 매 시간 체크
|
||||||
|
3. 해당 시간이 되면 generation_queue에 작업 추가
|
||||||
|
4. 워커가 순차적으로 처리:
|
||||||
|
- 펜션 정보 로드
|
||||||
|
- AI 콘텐츠 생성
|
||||||
|
- 음악 생성
|
||||||
|
- 영상 렌더링
|
||||||
|
- 자동 업로드 (YouTube/Instagram/TikTok)
|
||||||
|
5. 완료 후 이메일/푸시 알림 (선택)
|
||||||
|
|
@ -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,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 |
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"web":{"client_id":"671727489621-v119rtes771fnrifpmu2pjepja63j4sn.apps.googleusercontent.com","project_id":"grand-solstice-477822-s9","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost:3001/oauth2callback"]}}
|
{"web":{"client_id":"671727489621-v119rtes771fnrifpmu2pjepja63j4sn.apps.googleusercontent.com","project_id":"grand-solstice-477822-s9","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost:3001/api/youtube/oauth/callback","http://localhost:3001/oauth2callback"]}}
|
||||||
Binary file not shown.
391
server/db.js
391
server/db.js
|
|
@ -55,6 +55,66 @@ db.serialize(() => {
|
||||||
// 구독 만료일
|
// 구독 만료일
|
||||||
db.run("ALTER TABLE users ADD COLUMN subscription_expires_at DATETIME", (err) => {});
|
db.run("ALTER TABLE users ADD COLUMN subscription_expires_at DATETIME", (err) => {});
|
||||||
|
|
||||||
|
// 사용자 경험 레벨 (beginner, intermediate, pro)
|
||||||
|
db.run("ALTER TABLE users ADD COLUMN experience_level TEXT DEFAULT 'beginner'", (err) => {});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 자동 생성 설정 테이블
|
||||||
|
// ============================================
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS auto_generation_settings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER UNIQUE NOT NULL,
|
||||||
|
enabled INTEGER DEFAULT 0,
|
||||||
|
day_of_week INTEGER DEFAULT 1,
|
||||||
|
time_of_day TEXT DEFAULT '09:00',
|
||||||
|
pension_id INTEGER,
|
||||||
|
last_generated_at DATETIME,
|
||||||
|
next_scheduled_at DATETIME,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// 자동 업로드 플래그 추가 (Pro 사용자용)
|
||||||
|
db.run("ALTER TABLE auto_generation_settings ADD COLUMN auto_youtube INTEGER DEFAULT 1", (err) => {});
|
||||||
|
db.run("ALTER TABLE auto_generation_settings ADD COLUMN auto_instagram INTEGER DEFAULT 1", (err) => {});
|
||||||
|
db.run("ALTER TABLE auto_generation_settings ADD COLUMN auto_tiktok INTEGER DEFAULT 1", (err) => {});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 업로드 이력 테이블
|
||||||
|
// ============================================
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS upload_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
history_id INTEGER,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
external_id TEXT,
|
||||||
|
external_url TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(history_id) REFERENCES history(id) ON DELETE CASCADE
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 자동 생성 작업 큐 테이블
|
||||||
|
// ============================================
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS generation_queue (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
pension_id INTEGER,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
scheduled_at DATETIME,
|
||||||
|
started_at DATETIME,
|
||||||
|
completed_at DATETIME,
|
||||||
|
error_message TEXT,
|
||||||
|
result_video_path TEXT,
|
||||||
|
result_history_id INTEGER,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`);
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 펜션/브랜드 프로필 테이블 (다중 펜션 지원)
|
// 펜션/브랜드 프로필 테이블 (다중 펜션 지원)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -91,6 +151,76 @@ db.serialize(() => {
|
||||||
db.run("ALTER TABLE pension_profiles ADD COLUMN youtube_playlist_id TEXT", (err) => {});
|
db.run("ALTER TABLE pension_profiles ADD COLUMN youtube_playlist_id TEXT", (err) => {});
|
||||||
db.run("ALTER TABLE pension_profiles ADD COLUMN youtube_playlist_title TEXT", (err) => {});
|
db.run("ALTER TABLE pension_profiles ADD COLUMN youtube_playlist_title TEXT", (err) => {});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 펜션 이미지 테이블
|
||||||
|
// ============================================
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS pension_images (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pension_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
original_url TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
file_size INTEGER,
|
||||||
|
mime_type TEXT DEFAULT 'image/jpeg',
|
||||||
|
source TEXT DEFAULT 'crawl',
|
||||||
|
is_priority INTEGER DEFAULT 0,
|
||||||
|
used_count INTEGER DEFAULT 0,
|
||||||
|
last_used_at DATETIME,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// pension_images 우선순위 컬럼 추가 (기존 테이블용)
|
||||||
|
db.run("ALTER TABLE pension_images ADD COLUMN is_priority INTEGER DEFAULT 0", (err) => {});
|
||||||
|
db.run("ALTER TABLE pension_images ADD COLUMN used_count INTEGER DEFAULT 0", (err) => {});
|
||||||
|
db.run("ALTER TABLE pension_images ADD COLUMN last_used_at DATETIME", (err) => {});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 일일 자동 생성 설정 테이블
|
||||||
|
// ============================================
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS daily_auto_generation (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pension_id INTEGER NOT NULL UNIQUE,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
enabled INTEGER DEFAULT 0,
|
||||||
|
generation_time TEXT DEFAULT '09:00',
|
||||||
|
image_mode TEXT DEFAULT 'random',
|
||||||
|
random_count INTEGER DEFAULT 10,
|
||||||
|
auto_upload_youtube INTEGER DEFAULT 1,
|
||||||
|
auto_upload_instagram INTEGER DEFAULT 0,
|
||||||
|
auto_upload_tiktok INTEGER DEFAULT 0,
|
||||||
|
last_generated_at DATETIME,
|
||||||
|
next_scheduled_at DATETIME,
|
||||||
|
consecutive_failures INTEGER DEFAULT 0,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 자동 생성 로그 테이블
|
||||||
|
// ============================================
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS auto_generation_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pension_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
video_path TEXT,
|
||||||
|
youtube_video_id TEXT,
|
||||||
|
instagram_media_id TEXT,
|
||||||
|
tiktok_video_id TEXT,
|
||||||
|
images_used TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
started_at DATETIME,
|
||||||
|
completed_at DATETIME,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`);
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// YouTube 분석 데이터 캐시 테이블 (펜션별)
|
// YouTube 분석 데이터 캐시 테이블 (펜션별)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -451,6 +581,34 @@ db.serialize(() => {
|
||||||
FOREIGN KEY(related_request_id) REFERENCES credit_requests(id) ON DELETE SET NULL
|
FOREIGN KEY(related_request_id) REFERENCES credit_requests(id) ON DELETE SET NULL
|
||||||
)`);
|
)`);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 렌더링 작업 큐 테이블 (백그라운드 처리)
|
||||||
|
// ============================================
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS render_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
pension_id INTEGER,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
progress INTEGER DEFAULT 0,
|
||||||
|
input_data TEXT,
|
||||||
|
output_path TEXT,
|
||||||
|
history_id INTEGER,
|
||||||
|
error_message TEXT,
|
||||||
|
credits_charged INTEGER DEFAULT 1,
|
||||||
|
credits_refunded INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
started_at DATETIME,
|
||||||
|
completed_at DATETIME,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY(history_id) REFERENCES history(id) ON DELETE SET NULL
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// render_jobs 인덱스
|
||||||
|
db.run("CREATE INDEX IF NOT EXISTS idx_render_jobs_user ON render_jobs(user_id)");
|
||||||
|
db.run("CREATE INDEX IF NOT EXISTS idx_render_jobs_status ON render_jobs(status)");
|
||||||
|
db.run("CREATE INDEX IF NOT EXISTS idx_render_jobs_created ON render_jobs(created_at)");
|
||||||
|
|
||||||
// 기존 테이블에 business_name 컬럼 추가 (존재하지 않을 경우를 대비해 try-catch 대신 별도 실행)
|
// 기존 테이블에 business_name 컬럼 추가 (존재하지 않을 경우를 대비해 try-catch 대신 별도 실행)
|
||||||
// SQLite는 IF NOT EXISTS 컬럼 추가를 지원하지 않으므로, 에러를 무시하는 방식으로 처리하거나 스키마 버전을 관리해야 함.
|
// SQLite는 IF NOT EXISTS 컬럼 추가를 지원하지 않으므로, 에러를 무시하는 방식으로 처리하거나 스키마 버전을 관리해야 함.
|
||||||
// 여기서는 간단히 컬럼 추가 시도 후 에러 무시 패턴을 사용.
|
// 여기서는 간단히 컬럼 추가 시도 후 에러 무시 패턴을 사용.
|
||||||
|
|
@ -480,6 +638,239 @@ db.serialize(() => {
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
)`);
|
)`);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 축제 테이블 (TourAPI 연동)
|
||||||
|
// ============================================
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS festivals (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
content_id TEXT UNIQUE NOT NULL,
|
||||||
|
content_type_id TEXT DEFAULT '15',
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
overview TEXT,
|
||||||
|
addr1 TEXT,
|
||||||
|
addr2 TEXT,
|
||||||
|
zipcode TEXT,
|
||||||
|
area_code TEXT,
|
||||||
|
sigungu_code TEXT,
|
||||||
|
sido TEXT,
|
||||||
|
sigungu TEXT,
|
||||||
|
mapx REAL,
|
||||||
|
mapy REAL,
|
||||||
|
event_start_date TEXT,
|
||||||
|
event_end_date TEXT,
|
||||||
|
first_image TEXT,
|
||||||
|
first_image2 TEXT,
|
||||||
|
tel TEXT,
|
||||||
|
homepage TEXT,
|
||||||
|
place TEXT,
|
||||||
|
place_info TEXT,
|
||||||
|
play_time TEXT,
|
||||||
|
program TEXT,
|
||||||
|
use_fee TEXT,
|
||||||
|
age_limit TEXT,
|
||||||
|
sponsor1 TEXT,
|
||||||
|
sponsor1_tel TEXT,
|
||||||
|
sponsor2 TEXT,
|
||||||
|
sponsor2_tel TEXT,
|
||||||
|
sub_event TEXT,
|
||||||
|
booking_place TEXT,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
view_count INTEGER DEFAULT 0,
|
||||||
|
last_synced_at TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// 축제 인덱스
|
||||||
|
db.run("CREATE INDEX IF NOT EXISTS idx_festivals_area ON festivals(area_code, sigungu_code)");
|
||||||
|
db.run("CREATE INDEX IF NOT EXISTS idx_festivals_date ON festivals(event_start_date, event_end_date)");
|
||||||
|
db.run("CREATE INDEX IF NOT EXISTS idx_festivals_coords ON festivals(mapx, mapy)");
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 전국 펜션 마스터 테이블 (TourAPI 연동)
|
||||||
|
// ============================================
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS public_pensions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
source_id TEXT,
|
||||||
|
content_id TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
name_normalized TEXT,
|
||||||
|
address TEXT,
|
||||||
|
road_address TEXT,
|
||||||
|
jibun_address TEXT,
|
||||||
|
zipcode TEXT,
|
||||||
|
sido TEXT,
|
||||||
|
sigungu TEXT,
|
||||||
|
eupmyeondong TEXT,
|
||||||
|
area_code TEXT,
|
||||||
|
sigungu_code TEXT,
|
||||||
|
mapx REAL,
|
||||||
|
mapy REAL,
|
||||||
|
tel TEXT,
|
||||||
|
homepage TEXT,
|
||||||
|
thumbnail TEXT,
|
||||||
|
images TEXT,
|
||||||
|
checkin_time TEXT,
|
||||||
|
checkout_time TEXT,
|
||||||
|
room_count INTEGER,
|
||||||
|
room_type TEXT,
|
||||||
|
facilities TEXT,
|
||||||
|
parking TEXT,
|
||||||
|
reservation_url TEXT,
|
||||||
|
pet_allowed INTEGER DEFAULT 0,
|
||||||
|
pickup_available INTEGER DEFAULT 0,
|
||||||
|
cooking_available INTEGER DEFAULT 0,
|
||||||
|
barbecue_available INTEGER DEFAULT 0,
|
||||||
|
business_status TEXT DEFAULT '영업중',
|
||||||
|
license_date TEXT,
|
||||||
|
closure_date TEXT,
|
||||||
|
is_verified INTEGER DEFAULT 0,
|
||||||
|
is_claimed INTEGER DEFAULT 0,
|
||||||
|
claimed_by INTEGER,
|
||||||
|
claimed_at TEXT,
|
||||||
|
view_count INTEGER DEFAULT 0,
|
||||||
|
last_synced_at TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (claimed_by) REFERENCES users(id)
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// 펜션 인덱스
|
||||||
|
db.run("CREATE INDEX IF NOT EXISTS idx_public_pensions_sido ON public_pensions(sido)");
|
||||||
|
db.run("CREATE INDEX IF NOT EXISTS idx_public_pensions_sigungu ON public_pensions(sido, sigungu)");
|
||||||
|
db.run("CREATE INDEX IF NOT EXISTS idx_public_pensions_coords ON public_pensions(mapx, mapy)");
|
||||||
|
db.run("CREATE INDEX IF NOT EXISTS idx_public_pensions_name ON public_pensions(name_normalized)");
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 펜션-축제 매칭 테이블
|
||||||
|
// ============================================
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS pension_festival_matches (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pension_id INTEGER NOT NULL,
|
||||||
|
pension_type TEXT DEFAULT 'public',
|
||||||
|
festival_id INTEGER NOT NULL,
|
||||||
|
distance_km REAL,
|
||||||
|
travel_time_min INTEGER,
|
||||||
|
match_type TEXT,
|
||||||
|
match_score INTEGER DEFAULT 0,
|
||||||
|
is_featured INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (festival_id) REFERENCES festivals(id),
|
||||||
|
UNIQUE(pension_id, pension_type, festival_id)
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// 매칭 인덱스
|
||||||
|
db.run("CREATE INDEX IF NOT EXISTS idx_matches_pension ON pension_festival_matches(pension_id, pension_type)");
|
||||||
|
db.run("CREATE INDEX IF NOT EXISTS idx_matches_festival ON pension_festival_matches(festival_id)");
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 지역코드 테이블
|
||||||
|
// ============================================
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS area_codes (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
name_short TEXT,
|
||||||
|
name_en TEXT
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// 지역코드 초기 데이터
|
||||||
|
const areaCodes = [
|
||||||
|
['1', '서울특별시', '서울', 'Seoul'],
|
||||||
|
['2', '인천광역시', '인천', 'Incheon'],
|
||||||
|
['3', '대전광역시', '대전', 'Daejeon'],
|
||||||
|
['4', '대구광역시', '대구', 'Daegu'],
|
||||||
|
['5', '광주광역시', '광주', 'Gwangju'],
|
||||||
|
['6', '부산광역시', '부산', 'Busan'],
|
||||||
|
['7', '울산광역시', '울산', 'Ulsan'],
|
||||||
|
['8', '세종특별자치시', '세종', 'Sejong'],
|
||||||
|
['31', '경기도', '경기', 'Gyeonggi'],
|
||||||
|
['32', '강원특별자치도', '강원', 'Gangwon'],
|
||||||
|
['33', '충청북도', '충북', 'Chungbuk'],
|
||||||
|
['34', '충청남도', '충남', 'Chungnam'],
|
||||||
|
['35', '경상북도', '경북', 'Gyeongbuk'],
|
||||||
|
['36', '경상남도', '경남', 'Gyeongnam'],
|
||||||
|
['37', '전북특별자치도', '전북', 'Jeonbuk'],
|
||||||
|
['38', '전라남도', '전남', 'Jeonnam'],
|
||||||
|
['39', '제주특별자치도', '제주', 'Jeju'],
|
||||||
|
];
|
||||||
|
areaCodes.forEach(([code, name, nameShort, nameEn]) => {
|
||||||
|
db.run("INSERT OR IGNORE INTO area_codes (code, name, name_short, name_en) VALUES (?, ?, ?, ?)",
|
||||||
|
[code, name, nameShort, nameEn]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// pension_profiles에 좌표 컬럼 추가 (기존 테이블용)
|
||||||
|
db.run("ALTER TABLE pension_profiles ADD COLUMN mapx REAL", (err) => {});
|
||||||
|
db.run("ALTER TABLE pension_profiles ADD COLUMN mapy REAL", (err) => {});
|
||||||
|
db.run("ALTER TABLE pension_profiles ADD COLUMN area_code TEXT", (err) => {});
|
||||||
|
db.run("ALTER TABLE pension_profiles ADD COLUMN sigungu_code TEXT", (err) => {});
|
||||||
|
db.run("ALTER TABLE pension_profiles ADD COLUMN public_pension_id INTEGER", (err) => {});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// API 사용량 추적 테이블 (Gemini, Suno 등)
|
||||||
|
// ============================================
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS api_usage_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
service TEXT NOT NULL,
|
||||||
|
model TEXT,
|
||||||
|
endpoint TEXT,
|
||||||
|
user_id INTEGER,
|
||||||
|
tokens_input INTEGER DEFAULT 0,
|
||||||
|
tokens_output INTEGER DEFAULT 0,
|
||||||
|
image_count INTEGER DEFAULT 0,
|
||||||
|
audio_seconds REAL DEFAULT 0,
|
||||||
|
video_seconds REAL DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'success',
|
||||||
|
error_message TEXT,
|
||||||
|
latency_ms INTEGER DEFAULT 0,
|
||||||
|
cost_estimate REAL DEFAULT 0,
|
||||||
|
metadata TEXT,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// API 사용량 인덱스
|
||||||
|
db.run("CREATE INDEX IF NOT EXISTS idx_api_usage_service ON api_usage_logs(service, createdAt)");
|
||||||
|
db.run("CREATE INDEX IF NOT EXISTS idx_api_usage_user ON api_usage_logs(user_id, createdAt)");
|
||||||
|
db.run("CREATE INDEX IF NOT EXISTS idx_api_usage_date ON api_usage_logs(createdAt)");
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 시스템 설정 테이블 (쿠키, API 키 등)
|
||||||
|
// ============================================
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
setting_key TEXT UNIQUE NOT NULL,
|
||||||
|
setting_value TEXT,
|
||||||
|
description TEXT,
|
||||||
|
is_encrypted INTEGER DEFAULT 0,
|
||||||
|
updated_by INTEGER,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// API 일별 집계 테이블
|
||||||
|
// ============================================
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS api_usage_daily (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
service TEXT NOT NULL,
|
||||||
|
model TEXT,
|
||||||
|
total_calls INTEGER DEFAULT 0,
|
||||||
|
success_count INTEGER DEFAULT 0,
|
||||||
|
error_count INTEGER DEFAULT 0,
|
||||||
|
total_tokens_input INTEGER DEFAULT 0,
|
||||||
|
total_tokens_output INTEGER DEFAULT 0,
|
||||||
|
total_images INTEGER DEFAULT 0,
|
||||||
|
total_audio_seconds REAL DEFAULT 0,
|
||||||
|
total_video_seconds REAL DEFAULT 0,
|
||||||
|
total_cost_estimate REAL DEFAULT 0,
|
||||||
|
avg_latency_ms REAL DEFAULT 0,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(date, service, model)
|
||||||
|
)`);
|
||||||
|
|
||||||
// 기본 관리자 계정 생성 (존재하지 않을 경우)
|
// 기본 관리자 계정 생성 (존재하지 않을 경우)
|
||||||
const adminUsername = 'admin';
|
const adminUsername = 'admin';
|
||||||
const adminPassword = 'admin123'; // 초기 비밀번호
|
const adminPassword = 'admin123'; // 초기 비밀번호
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,209 @@ const { GoogleGenAI, Type, Modality } = require('@google/genai');
|
||||||
|
|
||||||
const GEMINI_API_KEY = process.env.VITE_GEMINI_API_KEY; // .env에서 API 키 로드
|
const GEMINI_API_KEY = process.env.VITE_GEMINI_API_KEY; // .env에서 API 키 로드
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// API 사용량 추적 유틸리티
|
||||||
|
// ============================================
|
||||||
|
let db = null;
|
||||||
|
try {
|
||||||
|
db = require('./db');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('DB not available for API usage logging');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 사용량 로깅
|
||||||
|
*/
|
||||||
|
const logApiUsage = async (data) => {
|
||||||
|
if (!db) return;
|
||||||
|
|
||||||
|
const {
|
||||||
|
service = 'gemini',
|
||||||
|
model = 'unknown',
|
||||||
|
endpoint = '',
|
||||||
|
userId = null,
|
||||||
|
tokensInput = 0,
|
||||||
|
tokensOutput = 0,
|
||||||
|
imageCount = 0,
|
||||||
|
audioSeconds = 0,
|
||||||
|
videoSeconds = 0,
|
||||||
|
status = 'success',
|
||||||
|
errorMessage = null,
|
||||||
|
latencyMs = 0,
|
||||||
|
costEstimate = 0,
|
||||||
|
metadata = null
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO api_usage_logs
|
||||||
|
(service, model, endpoint, user_id, tokens_input, tokens_output, image_count,
|
||||||
|
audio_seconds, video_seconds, status, error_message, latency_ms, cost_estimate, metadata)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[service, model, endpoint, userId, tokensInput, tokensOutput, imageCount,
|
||||||
|
audioSeconds, videoSeconds, status, errorMessage, latencyMs, costEstimate,
|
||||||
|
metadata ? JSON.stringify(metadata) : null],
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('API usage log error:', err);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 이미지 생성 모델 우선순위 시스템
|
||||||
|
// ============================================
|
||||||
|
const IMAGE_MODELS = [
|
||||||
|
{
|
||||||
|
id: 'gemini-2.0-flash-preview-image-generation',
|
||||||
|
name: 'Gemini 2.0 Flash Image (Preview)',
|
||||||
|
priority: 1,
|
||||||
|
costPerImage: 0.02, // 예상 비용 (USD)
|
||||||
|
maxRetries: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini-2.5-flash-image',
|
||||||
|
name: 'Gemini 2.5 Flash Image',
|
||||||
|
priority: 2,
|
||||||
|
costPerImage: 0.015,
|
||||||
|
maxRetries: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'imagen-3.0-generate-002',
|
||||||
|
name: 'Imagen 3',
|
||||||
|
priority: 3,
|
||||||
|
costPerImage: 0.03,
|
||||||
|
maxRetries: 1
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 생성을 여러 모델로 시도 (우선순위 기반 폴백)
|
||||||
|
*/
|
||||||
|
const generateImageWithFallback = async (ai, prompt, imageParts = [], options = {}) => {
|
||||||
|
const { aspectRatio = '16:9', userId = null } = options;
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const model of IMAGE_MODELS) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
for (let retry = 0; retry < model.maxRetries; retry++) {
|
||||||
|
try {
|
||||||
|
console.log(`[Image Gen] Trying ${model.name} (attempt ${retry + 1}/${model.maxRetries})`);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (model.id.startsWith('imagen')) {
|
||||||
|
// Imagen 모델용 API
|
||||||
|
response = await ai.models.generateImages({
|
||||||
|
model: model.id,
|
||||||
|
prompt: prompt,
|
||||||
|
config: {
|
||||||
|
numberOfImages: 1,
|
||||||
|
aspectRatio: aspectRatio
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageData = response.generatedImages?.[0];
|
||||||
|
if (imageData?.image?.imageBytes) {
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
await logApiUsage({
|
||||||
|
service: 'gemini',
|
||||||
|
model: model.id,
|
||||||
|
endpoint: 'generateImages',
|
||||||
|
userId,
|
||||||
|
imageCount: 1,
|
||||||
|
latencyMs: latency,
|
||||||
|
costEstimate: model.costPerImage,
|
||||||
|
metadata: { aspectRatio, prompt: prompt.substring(0, 100) }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
base64: imageData.image.imageBytes,
|
||||||
|
mimeType: 'image/png',
|
||||||
|
modelUsed: model.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Gemini 이미지 모델용 API
|
||||||
|
response = await ai.models.generateContent({
|
||||||
|
model: model.id,
|
||||||
|
contents: {
|
||||||
|
parts: [
|
||||||
|
{ text: prompt },
|
||||||
|
...imageParts
|
||||||
|
]
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
responseModalities: [Modality.IMAGE],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const part of response.candidates?.[0]?.content?.parts || []) {
|
||||||
|
if (part.inlineData) {
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
await logApiUsage({
|
||||||
|
service: 'gemini',
|
||||||
|
model: model.id,
|
||||||
|
endpoint: 'generateContent/image',
|
||||||
|
userId,
|
||||||
|
imageCount: 1,
|
||||||
|
latencyMs: latency,
|
||||||
|
costEstimate: model.costPerImage,
|
||||||
|
metadata: { aspectRatio, prompt: prompt.substring(0, 100) }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
base64: part.inlineData.data,
|
||||||
|
mimeType: part.inlineData.mimeType || 'image/png',
|
||||||
|
modelUsed: model.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('No image data in response');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
const errorMsg = error.message || 'Unknown error';
|
||||||
|
errors.push({ model: model.name, error: errorMsg, attempt: retry + 1 });
|
||||||
|
|
||||||
|
console.warn(`[Image Gen] ${model.name} failed (attempt ${retry + 1}): ${errorMsg}`);
|
||||||
|
|
||||||
|
// 로깅 (에러)
|
||||||
|
await logApiUsage({
|
||||||
|
service: 'gemini',
|
||||||
|
model: model.id,
|
||||||
|
endpoint: 'generateContent/image',
|
||||||
|
userId,
|
||||||
|
status: 'error',
|
||||||
|
errorMessage: errorMsg,
|
||||||
|
latencyMs: latency
|
||||||
|
});
|
||||||
|
|
||||||
|
// 429 (Rate Limit) 또는 500 에러면 다음 모델로
|
||||||
|
if (error.status === 429 || error.status === 500 ||
|
||||||
|
errorMsg.includes('quota') || errorMsg.includes('rate') ||
|
||||||
|
errorMsg.includes('Internal error')) {
|
||||||
|
console.log(`[Image Gen] Quota/rate limit hit, trying next model...`);
|
||||||
|
break; // 다음 모델로
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 에러는 재시도
|
||||||
|
if (retry < model.maxRetries - 1) {
|
||||||
|
await new Promise(r => setTimeout(r, 1000 * (retry + 1))); // 백오프
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 모델 실패
|
||||||
|
const errorSummary = errors.map(e => `${e.model}: ${e.error}`).join('; ');
|
||||||
|
throw new Error(`모든 이미지 생성 모델 실패: ${errorSummary}`);
|
||||||
|
};
|
||||||
|
|
||||||
const getVoiceName = (config) => {
|
const getVoiceName = (config) => {
|
||||||
if (config.gender === 'Female') {
|
if (config.gender === 'Female') {
|
||||||
if (config.tone === 'Bright' || config.tone === 'Energetic') return 'Zephyr';
|
if (config.tone === 'Bright' || config.tone === 'Energetic') return 'Zephyr';
|
||||||
|
|
@ -78,6 +281,27 @@ const generateCreativeContent = async (info) => {
|
||||||
? info.pensionCategories.map(cat => categoryMap[cat] || cat).join(', ')
|
? info.pensionCategories.map(cat => categoryMap[cat] || cat).join(', ')
|
||||||
: '일반 펜션';
|
: '일반 펜션';
|
||||||
|
|
||||||
|
// 근처 축제 정보 처리
|
||||||
|
let festivalContext = '';
|
||||||
|
if (info.nearbyFestivals && info.nearbyFestivals.length > 0) {
|
||||||
|
const festivalList = info.nearbyFestivals.map(f => {
|
||||||
|
let dateStr = '';
|
||||||
|
if (f.eventstartdate) {
|
||||||
|
const start = `${f.eventstartdate.slice(0, 4)}.${f.eventstartdate.slice(4, 6)}.${f.eventstartdate.slice(6, 8)}`;
|
||||||
|
const end = f.eventenddate ? `${f.eventenddate.slice(0, 4)}.${f.eventenddate.slice(4, 6)}.${f.eventenddate.slice(6, 8)}` : '';
|
||||||
|
dateStr = ` (${start}~${end})`;
|
||||||
|
}
|
||||||
|
return `- ${f.title}${dateStr}${f.addr1 ? ` @ ${f.addr1}` : ''}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
festivalContext = `
|
||||||
|
**근처 축제 정보** (콘텐츠에 자연스럽게 연동하라):
|
||||||
|
${festivalList}
|
||||||
|
- 이 축제들을 언급하며 "축제와 함께하는 특별한 펜션 여행"이라는 메시지를 전달하라.
|
||||||
|
- 축제 기간에 맞춰 방문할 수 있는 특별한 경험을 강조하라.
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
const prompt = `
|
const prompt = `
|
||||||
역할: 전문 ${targetLang} 카피라이터 및 작사가.
|
역할: 전문 ${targetLang} 카피라이터 및 작사가.
|
||||||
클라이언트: ${info.name}
|
클라이언트: ${info.name}
|
||||||
|
|
@ -86,6 +310,7 @@ const generateCreativeContent = async (info) => {
|
||||||
모드: ${info.audioMode} (Song=노래, Narration=내레이션)
|
모드: ${info.audioMode} (Song=노래, Narration=내레이션)
|
||||||
스타일: ${info.audioMode === 'Song' ? info.musicGenre : info.ttsConfig.tone}
|
스타일: ${info.audioMode === 'Song' ? info.musicGenre : info.ttsConfig.tone}
|
||||||
언어: **${targetLang}** 로 작성 필수.
|
언어: **${targetLang}** 로 작성 필수.
|
||||||
|
${festivalContext}
|
||||||
|
|
||||||
과제 1: 임팩트 있는 **${targetLang}** 광고 헤드라인 4개를 작성하라.
|
과제 1: 임팩트 있는 **${targetLang}** 광고 헤드라인 4개를 작성하라.
|
||||||
- **완벽한 ${targetLang} 사용**: 자연스럽고 세련된 현지 마케팅 표현 사용.
|
- **완벽한 ${targetLang} 사용**: 자연스럽고 세련된 현지 마케팅 표현 사용.
|
||||||
|
|
@ -198,7 +423,7 @@ const generateAdvancedSpeech = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 서버 사이드에서 광고 포스터 생성 - Gemini 3 Pro Image
|
* 서버 사이드에서 광고 포스터 생성 - 모델 우선순위 기반 폴백 시스템
|
||||||
* 업로드된 이미지들을 합성하고 브랜드 분위기에 맞는 고화질 포스터를 생성합니다.
|
* 업로드된 이미지들을 합성하고 브랜드 분위기에 맞는 고화질 포스터를 생성합니다.
|
||||||
*
|
*
|
||||||
* @param {object} info - 비즈니스 정보 객체 (BusinessInfo와 유사)
|
* @param {object} info - 비즈니스 정보 객체 (BusinessInfo와 유사)
|
||||||
|
|
@ -207,7 +432,8 @@ const generateAdvancedSpeech = async (
|
||||||
* @param {string} info.description - 브랜드 설명
|
* @param {string} info.description - 브랜드 설명
|
||||||
* @param {string} info.aspectRatio - 화면 비율
|
* @param {string} info.aspectRatio - 화면 비율
|
||||||
* @param {boolean} info.useAiImages - AI 이미지 생성 허용 여부
|
* @param {boolean} info.useAiImages - AI 이미지 생성 허용 여부
|
||||||
* @returns {Promise<{ base64: string; mimeType: string }>}
|
* @param {number} info.userId - 사용자 ID (API 로깅용)
|
||||||
|
* @returns {Promise<{ base64: string; mimeType: string; modelUsed?: string }>}
|
||||||
*/
|
*/
|
||||||
const generateAdPoster = async (info) => {
|
const generateAdPoster = async (info) => {
|
||||||
if (!GEMINI_API_KEY) {
|
if (!GEMINI_API_KEY) {
|
||||||
|
|
@ -219,8 +445,6 @@ const generateAdPoster = async (info) => {
|
||||||
inlineData: { mimeType: imageData.mimeType, data: imageData.base64 }
|
inlineData: { mimeType: imageData.mimeType, data: imageData.base64 }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const model = 'gemini-3-pro-image-preview';
|
|
||||||
|
|
||||||
let prompt = '';
|
let prompt = '';
|
||||||
|
|
||||||
if (imageParts.length > 0) {
|
if (imageParts.length > 0) {
|
||||||
|
|
@ -271,22 +495,39 @@ const generateAdPoster = async (info) => {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await ai.models.generateContent({
|
try {
|
||||||
model,
|
// 우선순위 기반 모델 폴백 시스템 사용
|
||||||
contents: {
|
const result = await generateImageWithFallback(ai, prompt, imageParts, {
|
||||||
parts: [
|
aspectRatio: info.aspectRatio || '16:9',
|
||||||
{ text: prompt },
|
userId: info.userId
|
||||||
...imageParts
|
|
||||||
]
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const part of response.candidates?.[0]?.content?.parts || []) {
|
console.log(`[Ad Poster] Generated successfully using: ${result.modelUsed}`);
|
||||||
if (part.inlineData) {
|
return result;
|
||||||
return { base64: part.inlineData.data, mimeType: part.inlineData.mimeType || 'image/png' };
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Ad Poster] All models failed:', error.message);
|
||||||
|
|
||||||
|
// 최종 폴백: 원본 이미지 반환
|
||||||
|
if (imageParts.length > 0 && info.images[0]) {
|
||||||
|
console.warn("[Ad Poster] Falling back to original image");
|
||||||
|
await logApiUsage({
|
||||||
|
service: 'gemini',
|
||||||
|
model: 'fallback-original',
|
||||||
|
endpoint: 'generateAdPoster',
|
||||||
|
userId: info.userId,
|
||||||
|
status: 'fallback',
|
||||||
|
errorMessage: error.message
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
base64: info.images[0].base64,
|
||||||
|
mimeType: info.images[0].mimeType,
|
||||||
|
modelUsed: 'Original Image (Fallback)'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
throw new Error("광고 포스터 이미지 생성에 실패했습니다.");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -300,7 +541,8 @@ const generateImageGallery = async (info, count) => {
|
||||||
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
|
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
|
||||||
}
|
}
|
||||||
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
||||||
const model = 'gemini-3-pro-image-preview';
|
// gemini-2.5-flash-image: production-ready, stable image generation model
|
||||||
|
const model = 'gemini-2.5-flash-image';
|
||||||
|
|
||||||
const perspectives = [
|
const perspectives = [
|
||||||
"Wide angle shot of the interior, welcoming atmosphere, cinematic lighting",
|
"Wide angle shot of the interior, welcoming atmosphere, cinematic lighting",
|
||||||
|
|
@ -324,6 +566,9 @@ const generateImageGallery = async (info, count) => {
|
||||||
const response = await ai.models.generateContent({
|
const response = await ai.models.generateContent({
|
||||||
model,
|
model,
|
||||||
contents: { parts: [{ text: prompt }] },
|
contents: { parts: [{ text: prompt }] },
|
||||||
|
config: {
|
||||||
|
responseModalities: [Modality.IMAGE],
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const part = response.candidates?.[0]?.content?.parts?.[0];
|
const part = response.candidates?.[0]?.content?.parts?.[0];
|
||||||
|
|
@ -792,6 +1037,172 @@ const generateYouTubeSEO = async (params) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비즈니스 DNA 분석 (Gemini 2.5 Flash + Google Search Grounding)
|
||||||
|
* 펜션/숙소의 브랜드 DNA를 분석하여 톤앤매너, 타겟 고객, 컬러, 키워드, 시각적 스타일을 추출
|
||||||
|
*
|
||||||
|
* @param {string} nameOrUrl - 펜션 이름 또는 URL
|
||||||
|
* @param {Array<{base64: string, mimeType: string}>} images - 펜션 이미지들 (선택)
|
||||||
|
* @param {number} userId - 사용자 ID (로깅용)
|
||||||
|
* @returns {Promise<object>} - BusinessDNA 객체
|
||||||
|
*/
|
||||||
|
const analyzeBusinessDNA = async (nameOrUrl, images = [], userId = null) => {
|
||||||
|
if (!GEMINI_API_KEY) {
|
||||||
|
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
||||||
|
|
||||||
|
// 이미지 파트 준비
|
||||||
|
const imageParts = images.map((imageData) => ({
|
||||||
|
inlineData: { mimeType: imageData.mimeType, data: imageData.base64 }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Google Search Grounding을 사용하는 프롬프트
|
||||||
|
const prompt = `
|
||||||
|
당신은 숙박업 브랜딩 전문가입니다.
|
||||||
|
|
||||||
|
**분석 대상**: "${nameOrUrl}"
|
||||||
|
|
||||||
|
**과제**: 이 펜션/숙소에 대해 Google 검색을 수행하여 다음 정보를 수집하고 분석하세요:
|
||||||
|
1. 공식 웹사이트, 예약 사이트(네이버, 야놀자, 여기어때 등)의 정보
|
||||||
|
2. 고객 리뷰 및 평점
|
||||||
|
3. 블로그 포스팅 및 SNS 언급
|
||||||
|
4. 사진에서 보이는 인테리어/익스테리어 스타일
|
||||||
|
|
||||||
|
${images.length > 0 ? '첨부된 이미지도 분석하여 시각적 스타일을 파악하세요.' : ''}
|
||||||
|
|
||||||
|
**출력 형식 (JSON)**:
|
||||||
|
반드시 아래 구조를 따르세요:
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "펜션 정식 명칭",
|
||||||
|
"tagline": "한 줄 슬로건 (10자 이내)",
|
||||||
|
"toneAndManner": {
|
||||||
|
"primary": "메인 톤앤매너 (예: Warm & Cozy, Luxurious & Elegant, Modern & Minimal)",
|
||||||
|
"secondary": "보조 톤앤매너 (선택)",
|
||||||
|
"description": "톤앤매너에 대한 상세 설명 (50자 내외)"
|
||||||
|
},
|
||||||
|
"targetCustomers": {
|
||||||
|
"primary": "주요 타겟 (예: Young Couples, Families with Kids, Solo Travelers)",
|
||||||
|
"secondary": ["보조 타겟1", "보조 타겟2"],
|
||||||
|
"ageRange": "예상 연령대 (예: 25-35)",
|
||||||
|
"characteristics": ["타겟 특성1", "타겟 특성2", "타겟 특성3"]
|
||||||
|
},
|
||||||
|
"brandColors": {
|
||||||
|
"primary": "#HEX코드 (브랜드 메인 컬러)",
|
||||||
|
"secondary": "#HEX코드 (보조 컬러)",
|
||||||
|
"accent": "#HEX코드 (악센트 컬러)",
|
||||||
|
"palette": ["#컬러1", "#컬러2", "#컬러3", "#컬러4", "#컬러5"],
|
||||||
|
"mood": "컬러가 주는 분위기 (예: Calm & Serene, Vibrant & Energetic)"
|
||||||
|
},
|
||||||
|
"keywords": {
|
||||||
|
"primary": ["핵심 키워드1", "핵심 키워드2", "핵심 키워드3", "핵심 키워드4", "핵심 키워드5"],
|
||||||
|
"secondary": ["부가 키워드1", "부가 키워드2", "부가 키워드3"],
|
||||||
|
"hashtags": ["#해시태그1", "#해시태그2", "#해시태그3", "#해시태그4", "#해시태그5"]
|
||||||
|
},
|
||||||
|
"visualStyle": {
|
||||||
|
"interior": "인테리어 스타일 (예: Scandinavian Minimalist, Korean Modern Hanok, Industrial Loft)",
|
||||||
|
"exterior": "외관 스타일 (예: Mountain Lodge, Seaside Villa, Forest Cabin)",
|
||||||
|
"atmosphere": "전반적인 분위기 (예: Serene & Peaceful, Romantic & Intimate, Vibrant & Social)",
|
||||||
|
"photoStyle": "추천 사진 스타일 (예: Warm Natural Light, Moody & Dramatic, Bright & Airy)",
|
||||||
|
"suggestedFilters": ["추천 필터1", "추천 필터2", "추천 필터3"]
|
||||||
|
},
|
||||||
|
"uniqueSellingPoints": [
|
||||||
|
"차별화 포인트1 (20자 이내)",
|
||||||
|
"차별화 포인트2",
|
||||||
|
"차별화 포인트3"
|
||||||
|
],
|
||||||
|
"mood": {
|
||||||
|
"primary": "메인 무드 (예: Relaxation, Adventure, Romance)",
|
||||||
|
"emotions": ["고객이 느낄 감정1", "감정2", "감정3", "감정4"]
|
||||||
|
},
|
||||||
|
"confidence": 0.85
|
||||||
|
}
|
||||||
|
|
||||||
|
**중요**:
|
||||||
|
- 모든 필드를 채워야 합니다
|
||||||
|
- 컬러는 반드시 유효한 HEX 코드로 작성 (#으로 시작)
|
||||||
|
- 컬러는 펜션의 분위기와 어울리게 선정
|
||||||
|
- 키워드와 해시태그는 한국어로 작성
|
||||||
|
- confidence는 정보의 신뢰도 (0.0~1.0)
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Google Search Grounding이 포함된 Gemini 2.5 Flash 호출
|
||||||
|
const response = await ai.models.generateContent({
|
||||||
|
model: 'gemini-2.5-flash-preview-05-20',
|
||||||
|
contents: {
|
||||||
|
parts: [
|
||||||
|
{ text: prompt },
|
||||||
|
...imageParts
|
||||||
|
]
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
temperature: 0.7,
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
// Google Search Grounding 활성화
|
||||||
|
tools: [{ googleSearch: {} }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
|
||||||
|
// 로깅
|
||||||
|
await logApiUsage({
|
||||||
|
service: 'gemini',
|
||||||
|
model: 'gemini-2.5-flash-preview-05-20',
|
||||||
|
endpoint: 'analyze-dna',
|
||||||
|
userId,
|
||||||
|
imageCount: images.length,
|
||||||
|
latencyMs: latency,
|
||||||
|
costEstimate: 0.01,
|
||||||
|
metadata: { nameOrUrl, hasImages: images.length > 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.text) {
|
||||||
|
try {
|
||||||
|
const dna = JSON.parse(response.text);
|
||||||
|
|
||||||
|
// 분석 시간 추가
|
||||||
|
dna.analyzedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// 검색에서 사용된 소스 추출 (grounding metadata)
|
||||||
|
if (response.candidates?.[0]?.groundingMetadata?.groundingChunks) {
|
||||||
|
dna.sources = response.candidates[0].groundingMetadata.groundingChunks
|
||||||
|
.filter(chunk => chunk.web?.uri)
|
||||||
|
.map(chunk => chunk.web.uri)
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dna;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("DNA JSON 파싱 오류:", response.text);
|
||||||
|
throw new Error("DNA 분석 결과 파싱에 실패했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("DNA 분석 응답이 비어있습니다.");
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
|
||||||
|
await logApiUsage({
|
||||||
|
service: 'gemini',
|
||||||
|
model: 'gemini-2.5-flash-preview-05-20',
|
||||||
|
endpoint: 'analyze-dna',
|
||||||
|
userId,
|
||||||
|
status: 'error',
|
||||||
|
errorMessage: error.message,
|
||||||
|
latencyMs: latency
|
||||||
|
});
|
||||||
|
|
||||||
|
console.error('[DNA Analysis] 오류:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
generateCreativeContent,
|
generateCreativeContent,
|
||||||
generateAdvancedSpeech,
|
generateAdvancedSpeech,
|
||||||
|
|
@ -802,4 +1213,5 @@ module.exports = {
|
||||||
extractTextEffectFromImage,
|
extractTextEffectFromImage,
|
||||||
generateVideoBackground,
|
generateVideoBackground,
|
||||||
generateYouTubeSEO,
|
generateYouTubeSEO,
|
||||||
|
analyzeBusinessDNA,
|
||||||
};
|
};
|
||||||
2732
server/index.js
2732
server/index.js
File diff suppressed because it is too large
Load Diff
|
|
@ -139,9 +139,16 @@ def connect_account():
|
||||||
'error_code': 'NO_DATA'
|
'error_code': 'NO_DATA'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
username = data.get('username', '').strip()
|
# None 값 안전하게 처리
|
||||||
password = data.get('password', '').strip()
|
raw_username = data.get('username')
|
||||||
verification_code = data.get('verification_code', '').strip()
|
raw_password = data.get('password')
|
||||||
|
raw_verification = data.get('verification_code')
|
||||||
|
|
||||||
|
logger.info(f"[DEBUG] raw values - username: {type(raw_username)}, password: {type(raw_password)}, verification: {type(raw_verification)}")
|
||||||
|
|
||||||
|
username = str(raw_username).strip() if raw_username else ''
|
||||||
|
password = str(raw_password).strip() if raw_password else ''
|
||||||
|
verification_code = str(raw_verification).strip() if raw_verification else ''
|
||||||
|
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,6 +7,8 @@
|
||||||
"start": "node index.js"
|
"start": "node index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@google-cloud/bigquery": "^8.1.1",
|
||||||
|
"@google-cloud/billing": "^5.1.1",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|
@ -15,8 +17,9 @@
|
||||||
"googleapis": "^166.0.0",
|
"googleapis": "^166.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
"puppeteer": "^22.0.0",
|
"puppeteer": "^19.0.0",
|
||||||
"puppeteer-screen-recorder": "^3.0.0",
|
"puppeteer-screen-recorder": "^3.0.0",
|
||||||
"resend": "^6.5.2",
|
"resend": "^6.5.2",
|
||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { BusinessInfo, TTSConfig, AspectRatio, Language } from '../types';
|
import { BusinessInfo, TTSConfig, AspectRatio, Language, BusinessDNA } from '../types';
|
||||||
import { decodeBase64, decodeAudioData, bufferToWaveBlob } from './audioUtils';
|
import { decodeBase64, decodeAudioData, bufferToWaveBlob } from './audioUtils';
|
||||||
|
|
||||||
const SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'];
|
const SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'];
|
||||||
|
|
@ -325,3 +325,56 @@ export const extractTextEffectFromImage = async (
|
||||||
throw new Error(e.message || "텍스트 스타일 분석에 실패했습니다.");
|
throw new Error(e.message || "텍스트 스타일 분석에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비즈니스 DNA 분석 (Gemini 2.5 Flash + Google Search Grounding) - 백엔드 프록시 이용
|
||||||
|
* 펜션/숙소의 브랜드 DNA를 분석하여 톤앤매너, 타겟 고객, 컬러, 키워드, 시각적 스타일을 추출
|
||||||
|
* @param {string} nameOrUrl - 펜션 이름 또는 URL
|
||||||
|
* @param {File[]} images - 펜션 이미지들 (선택)
|
||||||
|
* @param {(progress: string) => void} onProgress - 진행 상황 콜백
|
||||||
|
* @returns {Promise<BusinessDNA>} - 분석된 DNA 데이터
|
||||||
|
*/
|
||||||
|
export const analyzeBusinessDNA = async (
|
||||||
|
nameOrUrl: string,
|
||||||
|
images?: File[],
|
||||||
|
onProgress?: (progress: string) => void
|
||||||
|
): Promise<BusinessDNA> => {
|
||||||
|
try {
|
||||||
|
onProgress?.('Google 검색으로 정보 수집 중...');
|
||||||
|
|
||||||
|
// 이미지가 있으면 Base64로 변환
|
||||||
|
let imagesForBackend: { base64: string; mimeType: string }[] = [];
|
||||||
|
if (images && images.length > 0) {
|
||||||
|
onProgress?.('이미지 분석 준비 중...');
|
||||||
|
imagesForBackend = await Promise.all(
|
||||||
|
images.slice(0, 5).map(file => fileToBase64(file)) // 최대 5장만
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.('AI가 브랜드 DNA를 분석하고 있습니다...');
|
||||||
|
|
||||||
|
const response = await fetch('/api/gemini/analyze-dna', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
nameOrUrl,
|
||||||
|
images: imagesForBackend
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || `Server error: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
onProgress?.('DNA 분석 완료!');
|
||||||
|
return data.dna as BusinessDNA;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Analyze Business DNA (Frontend) Failed:", e);
|
||||||
|
throw new Error(e.message || "비즈니스 DNA 분석에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -58,7 +58,7 @@ export const crawlGooglePlace = async (
|
||||||
onProgress?: (msg: string) => void,
|
onProgress?: (msg: string) => void,
|
||||||
options?: CrawlOptions
|
options?: CrawlOptions
|
||||||
): Promise<Partial<BusinessInfo>> => {
|
): Promise<Partial<BusinessInfo>> => {
|
||||||
const maxImages = options?.maxImages ?? 15;
|
const maxImages = options?.maxImages ?? 100;
|
||||||
const existingFingerprints = options?.existingFingerprints ?? new Set<string>();
|
const existingFingerprints = options?.existingFingerprints ?? new Set<string>();
|
||||||
|
|
||||||
onProgress?.("Google 지도 정보 가져오는 중...");
|
onProgress?.("Google 지도 정보 가져오는 중...");
|
||||||
|
|
|
||||||
|
|
@ -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 || "인스타그램 프로필 정보를 가져오는데 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -58,7 +58,7 @@ export const getExistingFingerprints = async (existingImages: File[]): Promise<S
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface CrawlOptions {
|
export interface CrawlOptions {
|
||||||
maxImages?: number; // 가져올 최대 이미지 수 (기본값: 15)
|
maxImages?: number; // 가져올 최대 이미지 수 (기본값: 100)
|
||||||
existingFingerprints?: Set<string>; // 중복 검사용 기존 이미지 fingerprints
|
existingFingerprints?: Set<string>; // 중복 검사용 기존 이미지 fingerprints
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ export const crawlNaverPlace = async (
|
||||||
onProgress?: (msg: string) => void,
|
onProgress?: (msg: string) => void,
|
||||||
options?: CrawlOptions
|
options?: CrawlOptions
|
||||||
): Promise<Partial<BusinessInfo>> => {
|
): Promise<Partial<BusinessInfo>> => {
|
||||||
const maxImages = options?.maxImages ?? 15;
|
const maxImages = options?.maxImages ?? 100;
|
||||||
const existingFingerprints = options?.existingFingerprints ?? new Set<string>();
|
const existingFingerprints = options?.existingFingerprints ?? new Set<string>();
|
||||||
|
|
||||||
onProgress?.("네이버 플레이스 정보 가져오는 중 (서버 요청)...");
|
onProgress?.("네이버 플레이스 정보 가져오는 중 (서버 요청)...");
|
||||||
|
|
|
||||||
|
|
@ -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,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;
|
||||||
|
|
@ -11,17 +11,20 @@ import {
|
||||||
Sparkles,
|
Sparkles,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Settings,
|
Settings,
|
||||||
Building2
|
Building2,
|
||||||
|
PartyPopper
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { useUserLevel, LEVEL_INFO } from '../../contexts/UserLevelContext';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Switch } from '../ui/switch';
|
import { Switch } from '../ui/switch';
|
||||||
import { Progress } from '../ui/progress';
|
import { Progress } from '../ui/progress';
|
||||||
|
import { Badge } from '../ui/badge';
|
||||||
|
|
||||||
export type ViewType = 'dashboard' | 'new-project' | 'library' | 'assets' | 'pensions' | 'account' | 'settings';
|
export type ViewType = 'dashboard' | 'new-project' | 'library' | 'assets' | 'pensions' | 'festivals' | 'account' | 'settings';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
currentView: ViewType;
|
currentView: ViewType;
|
||||||
|
|
@ -63,6 +66,8 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const { level, features } = useUserLevel();
|
||||||
|
const levelInfo = LEVEL_INFO[level];
|
||||||
|
|
||||||
// Mock credits - replace with actual user data
|
// Mock credits - replace with actual user data
|
||||||
const credits = 850;
|
const credits = 850;
|
||||||
|
|
@ -77,54 +82,81 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center">
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center">
|
||||||
<Sparkles className="w-5 h-5 text-white" />
|
<Sparkles className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-bold text-foreground">CastAD Pro</span>
|
<div className="flex flex-col">
|
||||||
|
<span className="text-lg font-bold text-foreground">CastAD</span>
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 w-fit">
|
||||||
|
{levelInfo.icon} {levelInfo.name}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-3 py-2 space-y-1">
|
<nav className="flex-1 px-3 py-2 space-y-1">
|
||||||
|
{/* 항상 표시: 대시보드 */}
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={<LayoutDashboard className="w-5 h-5" />}
|
icon={<LayoutDashboard className="w-5 h-5" />}
|
||||||
label={user?.name || user?.username || t('dashWelcome').replace('!', '')}
|
label={user?.name || user?.username || t('dashWelcome').replace('!', '')}
|
||||||
active={currentView === 'dashboard'}
|
active={currentView === 'dashboard'}
|
||||||
onClick={() => onViewChange('dashboard')}
|
onClick={() => onViewChange('dashboard')}
|
||||||
/>
|
/>
|
||||||
|
{/* 항상 표시: 새 영상 만들기 */}
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={<Plus className="w-5 h-5" />}
|
icon={<Plus className="w-5 h-5" />}
|
||||||
label={t('dashNewProject')}
|
label={t('dashNewProject')}
|
||||||
active={currentView === 'new-project'}
|
active={currentView === 'new-project'}
|
||||||
onClick={() => onViewChange('new-project')}
|
onClick={() => onViewChange('new-project')}
|
||||||
/>
|
/>
|
||||||
|
{/* 항상 표시: 보관함 */}
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={<FolderOpen className="w-5 h-5" />}
|
icon={<FolderOpen className="w-5 h-5" />}
|
||||||
label={t('libTitle')}
|
label={t('libTitle')}
|
||||||
active={currentView === 'library'}
|
active={currentView === 'library'}
|
||||||
onClick={() => onViewChange('library')}
|
onClick={() => onViewChange('library')}
|
||||||
/>
|
/>
|
||||||
|
{/* 중급/프로만: 에셋 라이브러리 */}
|
||||||
|
{features.showAssetLibrary && (
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={<Image className="w-5 h-5" />}
|
icon={<Image className="w-5 h-5" />}
|
||||||
label={t('dashAssetManage')}
|
label={t('dashAssetManage')}
|
||||||
active={currentView === 'assets'}
|
active={currentView === 'assets'}
|
||||||
onClick={() => onViewChange('assets')}
|
onClick={() => onViewChange('assets')}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{/* 중급/프로만: 펜션 관리 */}
|
||||||
|
{features.showPensionManagement && (
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={<Building2 className="w-5 h-5" />}
|
icon={<Building2 className="w-5 h-5" />}
|
||||||
label={t('sidebarMyPensions')}
|
label={t('sidebarMyPensions')}
|
||||||
active={currentView === 'pensions'}
|
active={currentView === 'pensions'}
|
||||||
onClick={() => onViewChange('pensions')}
|
onClick={() => onViewChange('pensions')}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{/* 프로만: 축제 정보 */}
|
||||||
|
{features.showFestivalMenu && (
|
||||||
|
<NavItem
|
||||||
|
icon={<PartyPopper className="w-5 h-5" />}
|
||||||
|
label="축제 정보"
|
||||||
|
active={currentView === 'festivals'}
|
||||||
|
onClick={() => onViewChange('festivals')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* 항상 표시: 계정 */}
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={<User className="w-5 h-5" />}
|
icon={<User className="w-5 h-5" />}
|
||||||
label={t('accountTitle')}
|
label={t('accountTitle')}
|
||||||
active={currentView === 'account'}
|
active={currentView === 'account'}
|
||||||
onClick={() => onViewChange('account')}
|
onClick={() => onViewChange('account')}
|
||||||
/>
|
/>
|
||||||
|
{/* 중급/프로만: 비즈니스 설정 */}
|
||||||
|
{features.showAdvancedSettings && (
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={<Settings className="w-5 h-5" />}
|
icon={<Settings className="w-5 h-5" />}
|
||||||
label={t('settingsTitle')}
|
label={t('settingsTitle')}
|
||||||
active={currentView === 'settings'}
|
active={currentView === 'settings'}
|
||||||
onClick={() => onViewChange('settings')}
|
onClick={() => onViewChange('settings')}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Bottom Section */}
|
{/* Bottom Section */}
|
||||||
|
|
|
||||||
|
|
@ -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,219 @@
|
||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { UserLevel, FeatureFlags } from '../../types';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
|
interface UserLevelContextType {
|
||||||
|
level: UserLevel;
|
||||||
|
setLevel: (level: UserLevel) => Promise<void>;
|
||||||
|
features: FeatureFlags;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레벨별 기능 플래그 정의
|
||||||
|
const LEVEL_FEATURES: Record<UserLevel, FeatureFlags> = {
|
||||||
|
beginner: {
|
||||||
|
// 옵션 선택기 - 모두 숨김
|
||||||
|
showLanguageSelector: false,
|
||||||
|
showMusicGenreSelector: false,
|
||||||
|
showAudioModeSelector: false,
|
||||||
|
showMusicDurationSelector: false,
|
||||||
|
showTextEffectSelector: false,
|
||||||
|
showTransitionSelector: false,
|
||||||
|
showAspectRatioSelector: false,
|
||||||
|
showVisualStyleSelector: false,
|
||||||
|
showTtsConfig: false,
|
||||||
|
|
||||||
|
// 고급 기능 - 모두 숨김
|
||||||
|
showFestivalIntegration: false,
|
||||||
|
showAiImageGeneration: false,
|
||||||
|
showCustomCss: false,
|
||||||
|
showSeoEditor: false,
|
||||||
|
showThumbnailSelector: false,
|
||||||
|
|
||||||
|
// 메뉴 - 최소화
|
||||||
|
showAssetLibrary: false,
|
||||||
|
showPensionManagement: false,
|
||||||
|
showAdvancedSettings: false,
|
||||||
|
showFestivalMenu: false,
|
||||||
|
|
||||||
|
// 자동화 - 강제 ON
|
||||||
|
autoUpload: true,
|
||||||
|
showUploadOptions: false,
|
||||||
|
showScheduleSettings: false,
|
||||||
|
},
|
||||||
|
intermediate: {
|
||||||
|
// 옵션 선택기 - 기본 옵션만
|
||||||
|
showLanguageSelector: true,
|
||||||
|
showMusicGenreSelector: true,
|
||||||
|
showAudioModeSelector: true,
|
||||||
|
showMusicDurationSelector: true,
|
||||||
|
showTextEffectSelector: true,
|
||||||
|
showTransitionSelector: true,
|
||||||
|
showAspectRatioSelector: true,
|
||||||
|
showVisualStyleSelector: true,
|
||||||
|
showTtsConfig: false,
|
||||||
|
|
||||||
|
// 고급 기능 - 일부
|
||||||
|
showFestivalIntegration: false,
|
||||||
|
showAiImageGeneration: true,
|
||||||
|
showCustomCss: false,
|
||||||
|
showSeoEditor: false,
|
||||||
|
showThumbnailSelector: true,
|
||||||
|
|
||||||
|
// 메뉴 - 기본
|
||||||
|
showAssetLibrary: true,
|
||||||
|
showPensionManagement: true,
|
||||||
|
showAdvancedSettings: true,
|
||||||
|
showFestivalMenu: false,
|
||||||
|
|
||||||
|
// 자동화 - 강제 ON
|
||||||
|
autoUpload: true,
|
||||||
|
showUploadOptions: false,
|
||||||
|
showScheduleSettings: false,
|
||||||
|
},
|
||||||
|
pro: {
|
||||||
|
// 옵션 선택기 - 전체
|
||||||
|
showLanguageSelector: true,
|
||||||
|
showMusicGenreSelector: true,
|
||||||
|
showAudioModeSelector: true,
|
||||||
|
showMusicDurationSelector: true,
|
||||||
|
showTextEffectSelector: true,
|
||||||
|
showTransitionSelector: true,
|
||||||
|
showAspectRatioSelector: true,
|
||||||
|
showVisualStyleSelector: true,
|
||||||
|
showTtsConfig: true,
|
||||||
|
|
||||||
|
// 고급 기능 - 전체
|
||||||
|
showFestivalIntegration: true,
|
||||||
|
showAiImageGeneration: true,
|
||||||
|
showCustomCss: true,
|
||||||
|
showSeoEditor: true,
|
||||||
|
showThumbnailSelector: true,
|
||||||
|
|
||||||
|
// 메뉴 - 전체
|
||||||
|
showAssetLibrary: true,
|
||||||
|
showPensionManagement: true,
|
||||||
|
showAdvancedSettings: true,
|
||||||
|
showFestivalMenu: true,
|
||||||
|
|
||||||
|
// 자동화 - 선택 가능
|
||||||
|
autoUpload: false,
|
||||||
|
showUploadOptions: true,
|
||||||
|
showScheduleSettings: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레벨별 기본값 (Beginner용)
|
||||||
|
export const BEGINNER_DEFAULTS = {
|
||||||
|
language: 'KO' as const,
|
||||||
|
musicGenre: 'Auto' as const,
|
||||||
|
audioMode: 'Song' as const,
|
||||||
|
musicDuration: 'Short' as const,
|
||||||
|
visualStyle: 'Video' as const,
|
||||||
|
textEffect: 'Cinematic' as const,
|
||||||
|
transitionEffect: 'Mix' as const,
|
||||||
|
aspectRatio: '9:16' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserLevelContext = createContext<UserLevelContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const UserLevelProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { user, token } = useAuth();
|
||||||
|
const [level, setLevelState] = useState<UserLevel>('beginner');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// 사용자 레벨 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLevel = async () => {
|
||||||
|
if (!token || !user) {
|
||||||
|
setLevelState('beginner');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/level', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setLevelState(data.level || 'beginner');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load user level:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLevel();
|
||||||
|
}, [token, user]);
|
||||||
|
|
||||||
|
// 레벨 변경
|
||||||
|
const setLevel = useCallback(async (newLevel: UserLevel) => {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/level', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ level: newLevel }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setLevelState(newLevel);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update user level:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
// 현재 레벨의 기능 플래그
|
||||||
|
const features = useMemo(() => LEVEL_FEATURES[level], [level]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserLevelContext.Provider value={{ level, setLevel, features, isLoading }}>
|
||||||
|
{children}
|
||||||
|
</UserLevelContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUserLevel = () => {
|
||||||
|
const context = useContext(UserLevelContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useUserLevel must be used within a UserLevelProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레벨 정보 헬퍼
|
||||||
|
export const LEVEL_INFO = {
|
||||||
|
beginner: {
|
||||||
|
name: '쌩초보',
|
||||||
|
nameEn: 'Beginner',
|
||||||
|
icon: '🌱',
|
||||||
|
tagline: '다 알아서 해줘',
|
||||||
|
description: '원클릭으로 영상 생성, 자동 업로드',
|
||||||
|
features: ['원클릭 생성', '자동 업로드', '간단한 메뉴'],
|
||||||
|
},
|
||||||
|
intermediate: {
|
||||||
|
name: '중급',
|
||||||
|
nameEn: 'Intermediate',
|
||||||
|
icon: '🌿',
|
||||||
|
tagline: '조금만 선택할게',
|
||||||
|
description: '음악 장르, 언어 등 기본 옵션 선택',
|
||||||
|
features: ['장르 선택', '언어 선택', '기본 메뉴'],
|
||||||
|
},
|
||||||
|
pro: {
|
||||||
|
name: '프로',
|
||||||
|
nameEn: 'Pro',
|
||||||
|
icon: '🌳',
|
||||||
|
tagline: '내가 다 컨트롤',
|
||||||
|
description: '모든 옵션 제어, 축제 연동, 고급 설정',
|
||||||
|
features: ['모든 옵션', '축제 연동', '고급 설정'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -370,7 +370,7 @@ export const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
|
||||||
npTransZoom: '줌',
|
npTransZoom: '줌',
|
||||||
npTransSlide: '슬라이드',
|
npTransSlide: '슬라이드',
|
||||||
npTransWipe: '와이프',
|
npTransWipe: '와이프',
|
||||||
npMaxImages: '최대 15장 · JPG, PNG, WEBP',
|
npMaxImages: '최대 100장 · JPG, PNG, WEBP',
|
||||||
// Dashboard
|
// Dashboard
|
||||||
dashWelcome: '환영합니다!',
|
dashWelcome: '환영합니다!',
|
||||||
dashSubtitle: 'AI로 펜션 홍보 영상을 손쉽게 만들어보세요',
|
dashSubtitle: 'AI로 펜션 홍보 영상을 손쉽게 만들어보세요',
|
||||||
|
|
@ -838,7 +838,7 @@ export const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
|
||||||
npTransZoom: 'Zoom',
|
npTransZoom: 'Zoom',
|
||||||
npTransSlide: 'Slide',
|
npTransSlide: 'Slide',
|
||||||
npTransWipe: 'Wipe',
|
npTransWipe: 'Wipe',
|
||||||
npMaxImages: 'Max 15 images · JPG, PNG, WEBP',
|
npMaxImages: 'Max 100 images · JPG, PNG, WEBP',
|
||||||
// Dashboard
|
// Dashboard
|
||||||
dashWelcome: 'Welcome!',
|
dashWelcome: 'Welcome!',
|
||||||
dashSubtitle: 'Create pension promo videos easily with AI',
|
dashSubtitle: 'Create pension promo videos easily with AI',
|
||||||
|
|
@ -1306,7 +1306,7 @@ export const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
|
||||||
npTransZoom: 'ズーム',
|
npTransZoom: 'ズーム',
|
||||||
npTransSlide: 'スライド',
|
npTransSlide: 'スライド',
|
||||||
npTransWipe: 'ワイプ',
|
npTransWipe: 'ワイプ',
|
||||||
npMaxImages: '最大15枚 · JPG, PNG, WEBP',
|
npMaxImages: '最大100枚 · JPG, PNG, WEBP',
|
||||||
// Dashboard
|
// Dashboard
|
||||||
dashWelcome: 'ようこそ!',
|
dashWelcome: 'ようこそ!',
|
||||||
dashSubtitle: 'AIでペンションPR動画を簡単に作成',
|
dashSubtitle: 'AIでペンションPR動画を簡単に作成',
|
||||||
|
|
@ -1774,7 +1774,7 @@ export const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
|
||||||
npTransZoom: '缩放',
|
npTransZoom: '缩放',
|
||||||
npTransSlide: '滑动',
|
npTransSlide: '滑动',
|
||||||
npTransWipe: '擦除',
|
npTransWipe: '擦除',
|
||||||
npMaxImages: '最多15张 · JPG, PNG, WEBP',
|
npMaxImages: '最多100张 · JPG, PNG, WEBP',
|
||||||
// Dashboard
|
// Dashboard
|
||||||
dashWelcome: '欢迎!',
|
dashWelcome: '欢迎!',
|
||||||
dashSubtitle: '用AI轻松制作民宿宣传视频',
|
dashSubtitle: '用AI轻松制作民宿宣传视频',
|
||||||
|
|
@ -2242,7 +2242,7 @@ export const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
|
||||||
npTransZoom: 'ซูม',
|
npTransZoom: 'ซูม',
|
||||||
npTransSlide: 'เลื่อน',
|
npTransSlide: 'เลื่อน',
|
||||||
npTransWipe: 'กวาด',
|
npTransWipe: 'กวาด',
|
||||||
npMaxImages: 'สูงสุด 15 รูป · JPG, PNG, WEBP',
|
npMaxImages: 'สูงสุด 100 รูป · JPG, PNG, WEBP',
|
||||||
// Dashboard
|
// Dashboard
|
||||||
dashWelcome: 'ยินดีต้อนรับ!',
|
dashWelcome: 'ยินดีต้อนรับ!',
|
||||||
dashSubtitle: 'สร้างวิดีโอโปรโมทเพนชั่นง่ายๆ ด้วย AI',
|
dashSubtitle: 'สร้างวิดีโอโปรโมทเพนชั่นง่ายๆ ด้วย AI',
|
||||||
|
|
@ -2710,7 +2710,7 @@ export const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
|
||||||
npTransZoom: 'Phóng to',
|
npTransZoom: 'Phóng to',
|
||||||
npTransSlide: 'Trượt',
|
npTransSlide: 'Trượt',
|
||||||
npTransWipe: 'Xóa',
|
npTransWipe: 'Xóa',
|
||||||
npMaxImages: 'Tối đa 15 ảnh · JPG, PNG, WEBP',
|
npMaxImages: 'Tối đa 100 ảnh · JPG, PNG, WEBP',
|
||||||
// Dashboard
|
// Dashboard
|
||||||
dashWelcome: 'Chào mừng!',
|
dashWelcome: 'Chào mừng!',
|
||||||
dashSubtitle: 'Tạo video quảng cáo pension dễ dàng với AI',
|
dashSubtitle: 'Tạo video quảng cáo pension dễ dàng với AI',
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,15 @@ import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Info,
|
Info,
|
||||||
LogOut,
|
LogOut,
|
||||||
Coins
|
Coins,
|
||||||
|
PartyPopper,
|
||||||
|
Home,
|
||||||
|
MapPin,
|
||||||
|
Zap,
|
||||||
|
DollarSign
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type TabType = 'overview' | 'users' | 'content' | 'logs' | 'health' | 'settings' | 'credits';
|
type TabType = 'overview' | 'users' | 'content' | 'logs' | 'health' | 'settings' | 'credits' | 'api-usage';
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
users: {
|
users: {
|
||||||
|
|
@ -194,9 +199,41 @@ const AdminDashboard: React.FC = () => {
|
||||||
const [editCredits, setEditCredits] = useState('');
|
const [editCredits, setEditCredits] = useState('');
|
||||||
const [savingPlan, setSavingPlan] = useState(false);
|
const [savingPlan, setSavingPlan] = useState(false);
|
||||||
|
|
||||||
|
// API Usage states
|
||||||
|
const [apiUsageStats, setApiUsageStats] = useState<any>(null);
|
||||||
|
const [apiUsageByUser, setApiUsageByUser] = useState<any[]>([]);
|
||||||
|
const [apiUsageDays, setApiUsageDays] = useState(30);
|
||||||
|
const [apiUsageLoading, setApiUsageLoading] = useState(false);
|
||||||
|
const [selectedApiUser, setSelectedApiUser] = useState<any>(null);
|
||||||
|
const [apiUserDetail, setApiUserDetail] = useState<any>(null);
|
||||||
|
|
||||||
|
// Google Cloud Billing states (BigQuery)
|
||||||
|
const [gcpBillingData, setGcpBillingData] = useState<any>(null);
|
||||||
|
const [gcpBillingStatus, setGcpBillingStatus] = useState<any>(null);
|
||||||
|
const [gcpBillingLoading, setGcpBillingLoading] = useState(false);
|
||||||
|
|
||||||
|
// Data sync states
|
||||||
|
const [syncingFestivals, setSyncingFestivals] = useState(false);
|
||||||
|
const [syncingPensions, setSyncingPensions] = useState(false);
|
||||||
|
const [festivalStats, setFestivalStats] = useState<{stats: any[], total: number} | null>(null);
|
||||||
|
const [pensionStats, setPensionStats] = useState<{stats: any[], total: number} | null>(null);
|
||||||
|
const [lastSyncMessage, setLastSyncMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Cookie management states
|
||||||
|
const [cookieSettings, setCookieSettings] = useState<{
|
||||||
|
naver_cookies: { value: string; masked: string; updatedAt: string | null; source?: string };
|
||||||
|
instagram_cookies: { value: string; masked: string; updatedAt: string | null; source?: string };
|
||||||
|
} | null>(null);
|
||||||
|
const [editingCookie, setEditingCookie] = useState<'naver' | 'instagram' | null>(null);
|
||||||
|
const [cookieInputValue, setCookieInputValue] = useState('');
|
||||||
|
const [savingCookie, setSavingCookie] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin) navigate('/dashboard');
|
// currentUser가 로드된 후에만 권한 체크 (null이면 아직 로딩 중)
|
||||||
}, [isAdmin, navigate]);
|
if (currentUser !== null && !isAdmin) {
|
||||||
|
navigate('/app');
|
||||||
|
}
|
||||||
|
}, [currentUser, isAdmin, navigate]);
|
||||||
|
|
||||||
// Fetch functions
|
// Fetch functions
|
||||||
const fetchStats = useCallback(async () => {
|
const fetchStats = useCallback(async () => {
|
||||||
|
|
@ -271,6 +308,84 @@ const AdminDashboard: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
|
// API Usage fetch functions
|
||||||
|
const fetchApiUsageStats = useCallback(async () => {
|
||||||
|
setApiUsageLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/api-usage/stats?days=${apiUsageDays}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setApiUsageStats(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('API usage stats fetch error:', e);
|
||||||
|
} finally {
|
||||||
|
setApiUsageLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, apiUsageDays]);
|
||||||
|
|
||||||
|
const fetchApiUsageByUser = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/api-usage/by-user?days=${apiUsageDays}&limit=50`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setApiUsageByUser(data.users || []);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('API usage by user fetch error:', e);
|
||||||
|
}
|
||||||
|
}, [token, apiUsageDays]);
|
||||||
|
|
||||||
|
const fetchApiUserDetail = useCallback(async (userId: number) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/api-usage/user/${userId}?days=${apiUsageDays}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setApiUserDetail(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('API user detail fetch error:', e);
|
||||||
|
}
|
||||||
|
}, [token, apiUsageDays]);
|
||||||
|
|
||||||
|
// Google Cloud Billing fetch functions
|
||||||
|
const fetchGcpBillingStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/billing/status', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setGcpBillingStatus(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('GCP billing status fetch error:', e);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const fetchGcpBillingData = useCallback(async () => {
|
||||||
|
setGcpBillingLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/billing/dashboard?days=${apiUsageDays}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setGcpBillingData(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('GCP billing data fetch error:', e);
|
||||||
|
} finally {
|
||||||
|
setGcpBillingLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, apiUsageDays]);
|
||||||
|
|
||||||
// Credit fetch functions
|
// Credit fetch functions
|
||||||
const fetchCreditRequests = useCallback(async () => {
|
const fetchCreditRequests = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -301,6 +416,118 @@ const AdminDashboard: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
|
// Festival/Pension stats fetch functions
|
||||||
|
const fetchFestivalStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/festivals/stats/by-region', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) setFestivalStats(await res.json());
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Festival stats fetch error:', e);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const fetchPensionStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/pensions/stats/by-region', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) setPensionStats(await res.json());
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Pension stats fetch error:', e);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
// Cookie management functions
|
||||||
|
const fetchCookieSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/cookies', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) setCookieSettings(await res.json());
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Cookie settings fetch error:', e);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const handleSaveCookie = async (type: 'naver' | 'instagram') => {
|
||||||
|
setSavingCookie(true);
|
||||||
|
try {
|
||||||
|
const body: any = {};
|
||||||
|
if (type === 'naver') body.naver_cookies = cookieInputValue;
|
||||||
|
if (type === 'instagram') body.instagram_cookies = cookieInputValue;
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/cookies', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
await fetchCookieSettings();
|
||||||
|
setEditingCookie(null);
|
||||||
|
setCookieInputValue('');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Cookie save error:', e);
|
||||||
|
} finally {
|
||||||
|
setSavingCookie(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync handlers
|
||||||
|
const handleSyncFestivals = async () => {
|
||||||
|
setSyncingFestivals(true);
|
||||||
|
setLastSyncMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/sync/festivals', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setLastSyncMessage(data.message || '축제 동기화가 시작되었습니다.');
|
||||||
|
// Refresh stats after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchFestivalStats();
|
||||||
|
}, 3000);
|
||||||
|
} catch (e) {
|
||||||
|
setLastSyncMessage('축제 동기화 요청 실패');
|
||||||
|
} finally {
|
||||||
|
setSyncingFestivals(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSyncPensions = async () => {
|
||||||
|
setSyncingPensions(true);
|
||||||
|
setLastSyncMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/sync/pensions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setLastSyncMessage(data.message || '펜션 동기화가 시작되었습니다.');
|
||||||
|
// Refresh stats after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchPensionStats();
|
||||||
|
}, 3000);
|
||||||
|
} catch (e) {
|
||||||
|
setLastSyncMessage('펜션 동기화 요청 실패');
|
||||||
|
} finally {
|
||||||
|
setSyncingPensions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Credit action functions
|
// Credit action functions
|
||||||
const handleCreditAction = async (requestId: number, action: 'approve' | 'reject', adminNote?: string) => {
|
const handleCreditAction = async (requestId: number, action: 'approve' | 'reject', adminNote?: string) => {
|
||||||
setProcessingCredit(requestId);
|
setProcessingCredit(requestId);
|
||||||
|
|
@ -374,11 +601,17 @@ const AdminDashboard: React.FC = () => {
|
||||||
case 'health':
|
case 'health':
|
||||||
await fetchHealth();
|
await fetchHealth();
|
||||||
break;
|
break;
|
||||||
|
case 'settings':
|
||||||
|
await Promise.all([fetchFestivalStats(), fetchPensionStats(), fetchCookieSettings()]);
|
||||||
|
break;
|
||||||
|
case 'api-usage':
|
||||||
|
await Promise.all([fetchApiUsageStats(), fetchApiUsageByUser(), fetchGcpBillingStatus(), fetchGcpBillingData()]);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
loadData();
|
loadData();
|
||||||
}, [activeTab, fetchStats, fetchHealth, fetchUsers, fetchHistory, fetchLogs, fetchUploads, fetchCreditRequests, fetchCreditStats]);
|
}, [activeTab, fetchStats, fetchHealth, fetchUsers, fetchHistory, fetchLogs, fetchUploads, fetchCreditRequests, fetchCreditStats, fetchFestivalStats, fetchPensionStats, fetchApiUsageStats, fetchApiUsageByUser, fetchGcpBillingStatus, fetchGcpBillingData]);
|
||||||
|
|
||||||
// User actions
|
// User actions
|
||||||
const handleApprove = async (userId: number, approve: boolean) => {
|
const handleApprove = async (userId: number, approve: boolean) => {
|
||||||
|
|
@ -533,6 +766,7 @@ const AdminDashboard: React.FC = () => {
|
||||||
{ id: 'overview', label: '대시보드', icon: LayoutDashboard },
|
{ id: 'overview', label: '대시보드', icon: LayoutDashboard },
|
||||||
{ id: 'users', label: '회원 관리', icon: Users },
|
{ id: 'users', label: '회원 관리', icon: Users },
|
||||||
{ id: 'credits', label: '크레딧', icon: Coins },
|
{ id: 'credits', label: '크레딧', icon: Coins },
|
||||||
|
{ id: 'api-usage', label: 'API 사용량', icon: Zap },
|
||||||
{ id: 'content', label: '콘텐츠', icon: Video },
|
{ id: 'content', label: '콘텐츠', icon: Video },
|
||||||
{ id: 'logs', label: '활동 로그', icon: Activity },
|
{ id: 'logs', label: '활동 로그', icon: Activity },
|
||||||
{ id: 'health', label: '시스템', icon: Server },
|
{ id: 'health', label: '시스템', icon: Server },
|
||||||
|
|
@ -1344,6 +1578,404 @@ const AdminDashboard: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* API Usage Tab */}
|
||||||
|
{activeTab === 'api-usage' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold">API 사용량 모니터링</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={apiUsageDays}
|
||||||
|
onChange={(e) => setApiUsageDays(Number(e.target.value))}
|
||||||
|
className="px-3 py-1.5 rounded-lg border bg-background text-sm"
|
||||||
|
>
|
||||||
|
<option value={7}>최근 7일</option>
|
||||||
|
<option value={30}>최근 30일</option>
|
||||||
|
<option value={90}>최근 90일</option>
|
||||||
|
</select>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => { fetchApiUsageStats(); fetchApiUsageByUser(); }}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => window.open('https://aistudio.google.com/app/apikey', '_blank')}>
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
|
Google AI Studio
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview Cards */}
|
||||||
|
{apiUsageStats && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
<span className="text-xs">총 API 호출</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold">{apiUsageStats.overview?.totalCalls?.toLocaleString() || 0}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||||
|
<DollarSign className="w-4 h-4" />
|
||||||
|
<span className="text-xs">예상 비용 (USD)</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold">${apiUsageStats.overview?.totalCostUSD || '0.00'}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
<span className="text-xs">에러율</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold">{apiUsageStats.overview?.errorRate || 0}%</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span className="text-xs">평균 응답시간</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold">{apiUsageStats.overview?.avgLatencyMs || 0}ms</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||||
|
<XCircle className="w-4 h-4 text-red-500" />
|
||||||
|
<span className="text-xs">총 에러</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-red-500">{apiUsageStats.overview?.totalErrors || 0}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Service/Model Breakdown */}
|
||||||
|
{apiUsageStats?.byServiceModel && apiUsageStats.byServiceModel.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">서비스/모델별 사용량</CardTitle>
|
||||||
|
<CardDescription>각 AI 모델의 호출 수와 비용</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left py-2 px-3">서비스</th>
|
||||||
|
<th className="text-left py-2 px-3">모델</th>
|
||||||
|
<th className="text-right py-2 px-3">호출</th>
|
||||||
|
<th className="text-right py-2 px-3">성공</th>
|
||||||
|
<th className="text-right py-2 px-3">에러</th>
|
||||||
|
<th className="text-right py-2 px-3">이미지</th>
|
||||||
|
<th className="text-right py-2 px-3">비용 (USD)</th>
|
||||||
|
<th className="text-right py-2 px-3">평균 응답</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{apiUsageStats.byServiceModel.map((item: any, idx: number) => (
|
||||||
|
<tr key={idx} className="border-b hover:bg-muted/50">
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<Badge variant="outline">{item.service}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 font-mono text-xs">{item.model || '-'}</td>
|
||||||
|
<td className="text-right py-2 px-3">{item.total_calls?.toLocaleString()}</td>
|
||||||
|
<td className="text-right py-2 px-3 text-green-500">{item.success_count?.toLocaleString()}</td>
|
||||||
|
<td className="text-right py-2 px-3 text-red-500">{item.error_count || 0}</td>
|
||||||
|
<td className="text-right py-2 px-3">{item.total_images || 0}</td>
|
||||||
|
<td className="text-right py-2 px-3 font-medium">${(item.total_cost || 0).toFixed(4)}</td>
|
||||||
|
<td className="text-right py-2 px-3">{Math.round(item.avg_latency || 0)}ms</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Usage Ranking */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5" />
|
||||||
|
사용자별 API 사용량 (과금용)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>비용이 높은 순으로 정렬</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{apiUsageByUser.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left py-2 px-3">사용자</th>
|
||||||
|
<th className="text-left py-2 px-3">플랜</th>
|
||||||
|
<th className="text-right py-2 px-3">호출</th>
|
||||||
|
<th className="text-right py-2 px-3">이미지</th>
|
||||||
|
<th className="text-right py-2 px-3">오디오(초)</th>
|
||||||
|
<th className="text-right py-2 px-3">비용 (USD)</th>
|
||||||
|
<th className="text-right py-2 px-3">비용 (KRW)</th>
|
||||||
|
<th className="text-center py-2 px-3">상세</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{apiUsageByUser.map((user: any) => (
|
||||||
|
<tr key={user.userId} className="border-b hover:bg-muted/50">
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{user.username}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{user.email || user.name}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<Badge variant={user.planType === 'free' ? 'secondary' : 'default'}>
|
||||||
|
{user.planType}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-2 px-3">{user.totalCalls?.toLocaleString()}</td>
|
||||||
|
<td className="text-right py-2 px-3">{user.totalImages || 0}</td>
|
||||||
|
<td className="text-right py-2 px-3">{user.totalAudioSeconds || 0}</td>
|
||||||
|
<td className="text-right py-2 px-3 font-medium">${user.costUSD?.toFixed(4)}</td>
|
||||||
|
<td className="text-right py-2 px-3 font-medium text-green-600">₩{user.costKRW?.toLocaleString()}</td>
|
||||||
|
<td className="text-center py-2 px-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedApiUser(user);
|
||||||
|
fetchApiUserDetail(user.userId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-muted-foreground py-8">
|
||||||
|
아직 API 사용 기록이 없습니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent Errors */}
|
||||||
|
{apiUsageStats?.recentErrors && apiUsageStats.recentErrors.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||||
|
최근 에러 로그
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{apiUsageStats.recentErrors.map((error: any) => (
|
||||||
|
<div key={error.id} className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="font-medium">{error.service} / {error.model}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(error.createdAt).toLocaleString('ko-KR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-red-600 dark:text-red-400 text-xs font-mono">
|
||||||
|
{error.error_message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Google Cloud Billing (BigQuery) - 실제 결제 데이터 */}
|
||||||
|
<Card className="border-2 border-blue-200 dark:border-blue-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<DollarSign className="w-5 h-5 text-blue-500" />
|
||||||
|
Google Cloud 실제 결제 데이터
|
||||||
|
<Badge variant="outline" className="ml-2 text-xs">BigQuery</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
BigQuery Export를 통한 실제 청구 금액 (예상 비용이 아닌 실제 비용)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{gcpBillingLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
|
||||||
|
<span className="ml-2 text-muted-foreground">결제 데이터 로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : gcpBillingStatus?.available === false ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<AlertCircle className="w-12 h-12 text-yellow-500 mx-auto mb-3" />
|
||||||
|
<p className="font-medium">결제 데이터 대기 중</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
BigQuery Export 설정 후 24-48시간이 필요합니다.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
{gcpBillingStatus?.message}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => window.open('https://console.cloud.google.com/billing', '_blank')}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
|
Google Cloud Billing 콘솔
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : gcpBillingData?.error ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<XCircle className="w-12 h-12 text-red-500 mx-auto mb-3" />
|
||||||
|
<p className="text-red-500">{gcpBillingData.error}</p>
|
||||||
|
</div>
|
||||||
|
) : gcpBillingData ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 실제 비용 요약 */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-center">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">실제 총 비용 (USD)</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-600">${gcpBillingData.summary?.totalCostUSD || '0.00'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg text-center">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">실제 총 비용 (KRW)</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">₩{gcpBillingData.summary?.totalCostKRW?.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-muted rounded-lg text-center">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">사용 서비스</p>
|
||||||
|
<p className="text-2xl font-bold">{gcpBillingData.summary?.serviceCount || 0}개</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 서비스별 비용 */}
|
||||||
|
{gcpBillingData.byService && gcpBillingData.byService.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2 flex items-center gap-2">
|
||||||
|
<Server className="w-4 h-4" />
|
||||||
|
서비스별 실제 비용
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{gcpBillingData.byService.slice(0, 5).map((service: any, idx: number) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between p-2 bg-muted/50 rounded">
|
||||||
|
<span className="text-sm">{service.serviceName}</span>
|
||||||
|
<span className="font-mono text-sm font-medium">
|
||||||
|
${service.actualCost?.toFixed(4)} {service.currency}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gemini/Vertex AI 상세 */}
|
||||||
|
{gcpBillingData.geminiUsage && gcpBillingData.geminiUsage.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2 flex items-center gap-2">
|
||||||
|
<Zap className="w-4 h-4 text-purple-500" />
|
||||||
|
Gemini / Vertex AI 상세 비용
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||||
|
{gcpBillingData.geminiUsage.map((sku: any, idx: number) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between p-2 bg-purple-50 dark:bg-purple-900/20 rounded text-sm">
|
||||||
|
<span className="truncate flex-1 mr-2">{sku.skuName}</span>
|
||||||
|
<span className="font-mono">${sku.totalCost?.toFixed(6)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground text-right">
|
||||||
|
데이터 소스: {gcpBillingData.dataSource}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
결제 데이터를 불러오는 중...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* User Detail Modal */}
|
||||||
|
{selectedApiUser && apiUserDetail && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<Card className="w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{apiUserDetail.user?.username} 상세 사용량</CardTitle>
|
||||||
|
<CardDescription>{apiUserDetail.period} 기준</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => { setSelectedApiUser(null); setApiUserDetail(null); }}>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-y-auto max-h-[60vh]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="p-3 bg-muted rounded-lg text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">총 비용 (USD)</p>
|
||||||
|
<p className="text-xl font-bold">${apiUserDetail.summary?.totalCostUSD}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-muted rounded-lg text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">총 비용 (KRW)</p>
|
||||||
|
<p className="text-xl font-bold text-green-600">₩{apiUserDetail.summary?.totalCostKRW?.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-muted rounded-lg text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">총 호출</p>
|
||||||
|
<p className="text-xl font-bold">{apiUserDetail.summary?.totalCalls}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage by Model */}
|
||||||
|
{apiUserDetail.usageByModel && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">모델별 사용량</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{apiUserDetail.usageByModel.map((m: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center justify-between p-2 bg-muted/50 rounded text-sm">
|
||||||
|
<span className="font-mono text-xs">{m.model}</span>
|
||||||
|
<span>${(m.cost || 0).toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Daily Trend */}
|
||||||
|
{apiUserDetail.dailyTrend && apiUserDetail.dailyTrend.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">일별 추이</h4>
|
||||||
|
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||||
|
{apiUserDetail.dailyTrend.slice(0, 7).map((d: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center justify-between p-2 bg-muted/50 rounded text-sm">
|
||||||
|
<span>{d.date}</span>
|
||||||
|
<span>{d.calls}회 / ${(d.cost || 0).toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Content Tab */}
|
{/* Content Tab */}
|
||||||
{activeTab === 'content' && (
|
{activeTab === 'content' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -1650,13 +2282,281 @@ const AdminDashboard: React.FC = () => {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-2xl font-bold">설정</h2>
|
<h2 className="text-2xl font-bold">설정</h2>
|
||||||
|
|
||||||
|
{/* Sync Message */}
|
||||||
|
{lastSyncMessage && (
|
||||||
|
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30 text-blue-600 dark:text-blue-400 text-sm flex items-center gap-2">
|
||||||
|
<Info className="w-4 h-4 shrink-0" />
|
||||||
|
{lastSyncMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Data Sync Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>시스템 설정</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CardDescription>향후 업데이트 예정</CardDescription>
|
<Database className="w-5 h-5" />
|
||||||
|
데이터 동기화
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
TourAPI에서 축제 및 펜션 데이터를 동기화합니다. 매주 일요일 새벽 3시에 자동 실행됩니다.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-6">
|
||||||
<p className="text-muted-foreground">시스템 설정 기능이 준비 중입니다.</p>
|
{/* Festival Sync */}
|
||||||
|
<div className="p-4 rounded-lg border bg-card">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-orange-500/10 flex items-center justify-center">
|
||||||
|
<PartyPopper className="w-5 h-5 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">축제 데이터</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
전국 축제/행사 정보 (TourAPI)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSyncFestivals}
|
||||||
|
disabled={syncingFestivals}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{syncingFestivals ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
동기화 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
축제 동기화
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Festival Stats */}
|
||||||
|
{festivalStats && (
|
||||||
|
<div className="mt-4 pt-4 border-t">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-medium">현재 데이터</span>
|
||||||
|
<Badge variant="secondary">{festivalStats.total}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
{festivalStats.stats.slice(0, 8).map((item: any) => (
|
||||||
|
<div key={item.sido} className="text-xs p-2 rounded bg-muted/50">
|
||||||
|
<span className="text-muted-foreground">{item.sido}</span>
|
||||||
|
<span className="float-right font-medium">{item.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pension Sync */}
|
||||||
|
<div className="p-4 rounded-lg border bg-card">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
|
||||||
|
<Home className="w-5 h-5 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">펜션 데이터</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
전국 펜션/숙박 정보 (TourAPI)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSyncPensions}
|
||||||
|
disabled={syncingPensions}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{syncingPensions ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
동기화 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
펜션 동기화
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pension Stats */}
|
||||||
|
{pensionStats && (
|
||||||
|
<div className="mt-4 pt-4 border-t">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-medium">현재 데이터</span>
|
||||||
|
<Badge variant="secondary">{pensionStats.total}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
{pensionStats.stats.slice(0, 8).map((item: any) => (
|
||||||
|
<div key={item.sido} className="text-xs p-2 rounded bg-muted/50">
|
||||||
|
<span className="text-muted-foreground">{item.sido}</span>
|
||||||
|
<span className="float-right font-medium">{item.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sync Info */}
|
||||||
|
<div className="p-3 rounded-lg bg-muted/50 text-sm text-muted-foreground">
|
||||||
|
<p className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
자동 동기화: 매주 일요일 새벽 3시
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs">
|
||||||
|
수동 동기화 시 서버에서 백그라운드로 실행됩니다. 완료까지 몇 분이 소요될 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Cookie Management */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Key className="w-5 h-5" />
|
||||||
|
API 쿠키 관리
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
네이버 지도, 인스타그램 크롤링에 사용되는 인증 쿠키를 관리합니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Naver Cookies */}
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4 text-green-600" />
|
||||||
|
<span className="font-medium">네이버 지도 쿠키</span>
|
||||||
|
</div>
|
||||||
|
{editingCookie === 'naver' ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => { setEditingCookie(null); setCookieInputValue(''); }}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSaveCookie('naver')}
|
||||||
|
disabled={savingCookie}
|
||||||
|
>
|
||||||
|
{savingCookie ? <Loader2 className="w-4 h-4 animate-spin" /> : '저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingCookie('naver');
|
||||||
|
setCookieInputValue(cookieSettings?.naver_cookies?.value || '');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editingCookie === 'naver' ? (
|
||||||
|
<textarea
|
||||||
|
className="w-full h-24 p-2 text-xs font-mono border rounded bg-muted"
|
||||||
|
value={cookieInputValue}
|
||||||
|
onChange={(e) => setCookieInputValue(e.target.value)}
|
||||||
|
placeholder="NNB=xxx; JSESSIONID=xxx; ..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{cookieSettings?.naver_cookies?.masked || '설정되지 않음'}
|
||||||
|
{cookieSettings?.naver_cookies?.source === 'env' && (
|
||||||
|
<Badge variant="outline" className="ml-2">.env</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cookieSettings?.naver_cookies?.updatedAt && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
마지막 수정: {new Date(cookieSettings.naver_cookies.updatedAt).toLocaleString('ko-KR')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instagram Cookies */}
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Instagram className="w-4 h-4 text-pink-600" />
|
||||||
|
<span className="font-medium">인스타그램 쿠키</span>
|
||||||
|
</div>
|
||||||
|
{editingCookie === 'instagram' ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => { setEditingCookie(null); setCookieInputValue(''); }}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSaveCookie('instagram')}
|
||||||
|
disabled={savingCookie}
|
||||||
|
>
|
||||||
|
{savingCookie ? <Loader2 className="w-4 h-4 animate-spin" /> : '저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingCookie('instagram');
|
||||||
|
setCookieInputValue(cookieSettings?.instagram_cookies?.value || '');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editingCookie === 'instagram' ? (
|
||||||
|
<textarea
|
||||||
|
className="w-full h-24 p-2 text-xs font-mono border rounded bg-muted"
|
||||||
|
value={cookieInputValue}
|
||||||
|
onChange={(e) => setCookieInputValue(e.target.value)}
|
||||||
|
placeholder="sessionid=xxx; csrftoken=xxx; ds_user_id=xxx; ..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{cookieSettings?.instagram_cookies?.masked || '설정되지 않음'}
|
||||||
|
{cookieSettings?.instagram_cookies?.source === 'env' && (
|
||||||
|
<Badge variant="outline" className="ml-2">.env</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cookieSettings?.instagram_cookies?.updatedAt && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
마지막 수정: {new Date(cookieSettings.instagram_cookies.updatedAt).toLocaleString('ko-KR')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 text-xs bg-amber-50 dark:bg-amber-900/20 rounded-lg">
|
||||||
|
<p className="font-medium text-amber-800 dark:text-amber-200">쿠키 획득 방법:</p>
|
||||||
|
<ol className="mt-1 ml-4 list-decimal text-amber-700 dark:text-amber-300">
|
||||||
|
<li>브라우저에서 해당 서비스에 로그인</li>
|
||||||
|
<li>개발자 도구 (F12) → Application → Cookies</li>
|
||||||
|
<li>모든 쿠키를 "이름=값; 이름=값; ..." 형식으로 복사</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { ThemeProvider } from '../contexts/ThemeContext';
|
import { ThemeProvider } from '../contexts/ThemeContext';
|
||||||
|
import { UserLevelProvider } from '../contexts/UserLevelContext';
|
||||||
import Sidebar, { ViewType } from '../components/layout/Sidebar';
|
import Sidebar, { ViewType } from '../components/layout/Sidebar';
|
||||||
import TopHeader from '../components/layout/TopHeader';
|
import TopHeader from '../components/layout/TopHeader';
|
||||||
import OnboardingDialog from '../components/OnboardingDialog';
|
import OnboardingDialog from '../components/OnboardingDialog';
|
||||||
|
import PensionOnboardingDialog from '../components/PensionOnboardingDialog';
|
||||||
import DashboardView from '../views/DashboardView';
|
import DashboardView from '../views/DashboardView';
|
||||||
import NewProjectView from '../views/NewProjectView';
|
import NewProjectView from '../views/NewProjectView';
|
||||||
import LibraryView from '../views/LibraryView';
|
import LibraryView from '../views/LibraryView';
|
||||||
|
|
@ -12,6 +14,7 @@ import AssetsView from '../views/AssetsView';
|
||||||
import AccountView from '../views/AccountView';
|
import AccountView from '../views/AccountView';
|
||||||
import SettingsView from '../views/SettingsView';
|
import SettingsView from '../views/SettingsView';
|
||||||
import PensionsView from '../views/PensionsView';
|
import PensionsView from '../views/PensionsView';
|
||||||
|
import FestivalsView from '../views/FestivalsView';
|
||||||
import { GeneratedAssets } from '../../types';
|
import { GeneratedAssets } from '../../types';
|
||||||
|
|
||||||
const CastADApp: React.FC = () => {
|
const CastADApp: React.FC = () => {
|
||||||
|
|
@ -92,6 +95,7 @@ const CastADApp: React.FC = () => {
|
||||||
setSelectedAsset(asset);
|
setSelectedAsset(asset);
|
||||||
setCurrentView('library');
|
setCurrentView('library');
|
||||||
}}
|
}}
|
||||||
|
onViewFestivals={() => handleViewChange('festivals')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'new-project':
|
case 'new-project':
|
||||||
|
|
@ -114,6 +118,23 @@ const CastADApp: React.FC = () => {
|
||||||
return <AssetsView />;
|
return <AssetsView />;
|
||||||
case 'pensions':
|
case 'pensions':
|
||||||
return <PensionsView />;
|
return <PensionsView />;
|
||||||
|
case 'festivals':
|
||||||
|
return (
|
||||||
|
<FestivalsView
|
||||||
|
onCreateWithFestival={(festival) => {
|
||||||
|
// Store festival data and navigate to new project
|
||||||
|
sessionStorage.setItem('preselectedFestival', JSON.stringify({
|
||||||
|
id: festival.id,
|
||||||
|
title: festival.title,
|
||||||
|
addr1: festival.addr1,
|
||||||
|
sido: festival.sido,
|
||||||
|
event_start_date: festival.event_start_date,
|
||||||
|
event_end_date: festival.event_end_date
|
||||||
|
}));
|
||||||
|
handleViewChange('new-project');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case 'account':
|
case 'account':
|
||||||
return <AccountView />;
|
return <AccountView />;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
|
|
@ -139,6 +160,8 @@ const CastADApp: React.FC = () => {
|
||||||
return { breadcrumb: '', title: '에셋 라이브러리' };
|
return { breadcrumb: '', title: '에셋 라이브러리' };
|
||||||
case 'pensions':
|
case 'pensions':
|
||||||
return { breadcrumb: '', title: '내 펜션 관리' };
|
return { breadcrumb: '', title: '내 펜션 관리' };
|
||||||
|
case 'festivals':
|
||||||
|
return { breadcrumb: '', title: '전국 축제 정보' };
|
||||||
case 'account':
|
case 'account':
|
||||||
return { breadcrumb: '', title: '계정 관리' };
|
return { breadcrumb: '', title: '계정 관리' };
|
||||||
case 'settings':
|
case 'settings':
|
||||||
|
|
@ -152,6 +175,7 @@ const CastADApp: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<UserLevelProvider>
|
||||||
<div className="flex h-screen bg-background overflow-hidden">
|
<div className="flex h-screen bg-background overflow-hidden">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
currentView={currentView}
|
currentView={currentView}
|
||||||
|
|
@ -167,6 +191,9 @@ const CastADApp: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
{/* Onboarding for first-time users */}
|
{/* Onboarding for first-time users */}
|
||||||
<OnboardingDialog onComplete={() => handleViewChange('new-project')} />
|
<OnboardingDialog onComplete={() => handleViewChange('new-project')} />
|
||||||
|
{/* Pension onboarding for users without pensions */}
|
||||||
|
<PensionOnboardingDialog />
|
||||||
|
</UserLevelProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import KoreaMap from '../components/KoreaMap';
|
||||||
import { Button } from '../components/ui/button';
|
import { Button } from '../components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../components/ui/card';
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
import { Badge } from '../components/ui/badge';
|
import { Badge } from '../components/ui/badge';
|
||||||
|
|
@ -39,7 +40,10 @@ import {
|
||||||
Share2,
|
Share2,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Youtube,
|
Youtube,
|
||||||
Quote
|
Quote,
|
||||||
|
PartyPopper,
|
||||||
|
Home,
|
||||||
|
MapPin
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// Success case data - expanded
|
// Success case data - expanded
|
||||||
|
|
@ -357,10 +361,50 @@ const FloatingHearts: React.FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface RegionStat {
|
||||||
|
sido: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthStat {
|
||||||
|
yearMonth: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FestivalMonthlyStats {
|
||||||
|
stats: MonthStat[];
|
||||||
|
total: number;
|
||||||
|
currentMonth: MonthStat;
|
||||||
|
nextMonth: MonthStat;
|
||||||
|
}
|
||||||
|
|
||||||
const LandingPage: React.FC = () => {
|
const LandingPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [currentCase, setCurrentCase] = useState(0);
|
const [currentCase, setCurrentCase] = useState(0);
|
||||||
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [festivalStats, setFestivalStats] = useState<{ stats: RegionStat[], total: number } | null>(null);
|
||||||
|
const [festivalMonthlyStats, setFestivalMonthlyStats] = useState<FestivalMonthlyStats | null>(null);
|
||||||
|
const [pensionStats, setPensionStats] = useState<{ stats: RegionStat[], total: number } | null>(null);
|
||||||
|
|
||||||
|
// Fetch festival and pension stats
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const [festRes, festMonthRes, pensRes] = await Promise.all([
|
||||||
|
fetch('/api/festivals/stats/by-region'),
|
||||||
|
fetch('/api/festivals/stats/by-month'),
|
||||||
|
fetch('/api/pensions/stats/by-region')
|
||||||
|
]);
|
||||||
|
if (festRes.ok) setFestivalStats(await festRes.json());
|
||||||
|
if (festMonthRes.ok) setFestivalMonthlyStats(await festMonthRes.json());
|
||||||
|
if (pensRes.ok) setPensionStats(await pensRes.json());
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch stats:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
|
|
@ -1185,6 +1229,156 @@ const LandingPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ==================== FESTIVAL & PENSION STATS ==================== */}
|
||||||
|
{(festivalStats || pensionStats) && (
|
||||||
|
<section className="py-16 px-4 bg-muted/30">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<Badge variant="outline" className="mb-4">
|
||||||
|
<MapPin className="w-4 h-4 mr-2" />
|
||||||
|
전국 축제 & 펜션 데이터
|
||||||
|
</Badge>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold mb-4">
|
||||||
|
전국 축제와 함께하는 펜션 마케팅
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
|
||||||
|
주변 축제 정보를 활용해 더 효과적인 마케팅 영상을 만들어 보세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Korea Map - Center */}
|
||||||
|
{festivalStats && (
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="flex items-center justify-center gap-2">
|
||||||
|
<MapPin className="w-5 h-5 text-orange-500" />
|
||||||
|
전국 축제 지도
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
지역을 클릭하면 해당 지역 축제를 확인할 수 있습니다
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<KoreaMap
|
||||||
|
data={festivalStats.stats}
|
||||||
|
onRegionClick={(sido) => {
|
||||||
|
// 추후 지역 필터링 기능 연결
|
||||||
|
console.log('Selected region:', sido);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
{/* Festival Stats */}
|
||||||
|
{festivalStats && (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardHeader className="bg-gradient-to-r from-orange-500/10 to-amber-500/10 border-b">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-orange-500/20 flex items-center justify-center">
|
||||||
|
<PartyPopper className="w-6 h-6 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>전국 축제</CardTitle>
|
||||||
|
<CardDescription>현재 진행/예정 축제</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-3xl font-bold text-orange-500">{festivalStats.total}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">개 축제</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 space-y-4">
|
||||||
|
{/* Monthly Stats */}
|
||||||
|
{festivalMonthlyStats && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="p-3 rounded-lg bg-orange-500/10 border border-orange-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Calendar className="w-4 h-4 text-orange-500" />
|
||||||
|
<span className="text-sm font-medium">{festivalMonthlyStats.currentMonth.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-orange-600">{festivalMonthlyStats.currentMonth.count}개</p>
|
||||||
|
<p className="text-xs text-muted-foreground">이번 달 축제</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Calendar className="w-4 h-4 text-amber-500" />
|
||||||
|
<span className="text-sm font-medium">{festivalMonthlyStats.nextMonth.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-amber-600">{festivalMonthlyStats.nextMonth.count}개</p>
|
||||||
|
<p className="text-xs text-muted-foreground">다음 달 축제</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="outline" className="w-full gap-2" asChild>
|
||||||
|
<Link to="/login">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
축제 정보 보기
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pension Stats */}
|
||||||
|
{pensionStats && (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardHeader className="bg-gradient-to-r from-green-500/10 to-emerald-500/10 border-b">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-green-500/20 flex items-center justify-center">
|
||||||
|
<Home className="w-6 h-6 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>전국 펜션</CardTitle>
|
||||||
|
<CardDescription>한국관광공사 등록 펜션</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-3xl font-bold text-green-500">{pensionStats.total.toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">개 펜션</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 space-y-4">
|
||||||
|
{/* Top Regions */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">지역별 TOP 6</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{pensionStats.stats.slice(0, 6).map((item) => (
|
||||||
|
<div key={item.sido} className="p-2 rounded-lg bg-muted/50 text-center">
|
||||||
|
<p className="font-bold text-lg">{item.count}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{item.sido}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" className="w-full gap-2" asChild>
|
||||||
|
<Link to="/register">
|
||||||
|
<Building className="w-4 h-4" />
|
||||||
|
내 펜션 등록하기
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
* 한국관광공사 TourAPI 기반 실시간 데이터
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ==================== FINAL CTA ==================== */}
|
{/* ==================== FINAL CTA ==================== */}
|
||||||
<section className="py-16 px-4">
|
<section className="py-16 px-4">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import React, { useState } from 'react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
import { useUserLevel, LEVEL_INFO } from '../contexts/UserLevelContext';
|
||||||
|
import LevelSelector from '../components/settings/LevelSelector';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
import { Button } from '../components/ui/button';
|
import { Button } from '../components/ui/button';
|
||||||
import { Input } from '../components/ui/input';
|
import { Input } from '../components/ui/input';
|
||||||
|
|
@ -36,6 +38,8 @@ import { Language, PLAN_CONFIG, PlanType } from '../../types';
|
||||||
|
|
||||||
const AccountView: React.FC = () => {
|
const AccountView: React.FC = () => {
|
||||||
const { user, logout, token } = useAuth();
|
const { user, logout, token } = useAuth();
|
||||||
|
const { level } = useUserLevel();
|
||||||
|
const levelInfo = LEVEL_INFO[level];
|
||||||
|
|
||||||
// 플랜 정보 가져오기
|
// 플랜 정보 가져오기
|
||||||
const planType = (user?.plan_type || 'free') as PlanType;
|
const planType = (user?.plan_type || 'free') as PlanType;
|
||||||
|
|
@ -126,6 +130,29 @@ const AccountView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* User Level Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">{levelInfo.icon}</span>
|
||||||
|
사용 모드
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
나에게 맞는 사용 모드를 선택하세요
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-lg px-3 py-1">
|
||||||
|
{levelInfo.icon} {levelInfo.name}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<LevelSelector showTitle={false} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Profile Section */}
|
{/* Profile Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { GeneratedAssets } from '../../types';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { GeneratedAssets, NearbyFestival } from '../../types';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
import { Button } from '../components/ui/button';
|
import { Button } from '../components/ui/button';
|
||||||
import { Badge } from '../components/ui/badge';
|
import { Badge } from '../components/ui/badge';
|
||||||
|
|
@ -17,7 +18,11 @@ import {
|
||||||
Sparkles,
|
Sparkles,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Music,
|
Music,
|
||||||
Mic
|
Mic,
|
||||||
|
PartyPopper,
|
||||||
|
MapPin,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface DashboardViewProps {
|
interface DashboardViewProps {
|
||||||
|
|
@ -25,15 +30,79 @@ interface DashboardViewProps {
|
||||||
onCreateNew: () => void;
|
onCreateNew: () => void;
|
||||||
onViewLibrary: () => void;
|
onViewLibrary: () => void;
|
||||||
onSelectAsset?: (asset: GeneratedAssets) => void;
|
onSelectAsset?: (asset: GeneratedAssets) => void;
|
||||||
|
onViewFestivals?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DashboardView: React.FC<DashboardViewProps> = ({
|
const DashboardView: React.FC<DashboardViewProps> = ({
|
||||||
libraryItems,
|
libraryItems,
|
||||||
onCreateNew,
|
onCreateNew,
|
||||||
onViewLibrary,
|
onViewLibrary,
|
||||||
onSelectAsset
|
onSelectAsset,
|
||||||
|
onViewFestivals
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const { token } = useAuth();
|
||||||
|
|
||||||
|
// Festival state
|
||||||
|
const [upcomingFestivals, setUpcomingFestivals] = useState<NearbyFestival[]>([]);
|
||||||
|
const [loadingFestivals, setLoadingFestivals] = useState(true);
|
||||||
|
const [userRegion, setUserRegion] = useState<string>('');
|
||||||
|
|
||||||
|
// Fetch upcoming festivals based on user's pension region
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFestivals = async () => {
|
||||||
|
try {
|
||||||
|
// First, try to get user's default pension region
|
||||||
|
const pensionRes = await fetch('/api/profile/pensions', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
let region = '';
|
||||||
|
if (pensionRes.ok) {
|
||||||
|
const pensions = await pensionRes.json();
|
||||||
|
const defaultPension = pensions.find((p: any) => p.is_default) || pensions[0];
|
||||||
|
if (defaultPension?.region) {
|
||||||
|
region = defaultPension.region;
|
||||||
|
setUserRegion(region);
|
||||||
|
} else if (defaultPension?.address) {
|
||||||
|
// Extract region from address
|
||||||
|
const parts = defaultPension.address.split(' ');
|
||||||
|
if (parts.length > 0) {
|
||||||
|
region = parts[0].replace(/도|시|특별시|광역시|특별자치시|특별자치도/g, '');
|
||||||
|
setUserRegion(region);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch festivals (with region filter if available)
|
||||||
|
const festivalUrl = region
|
||||||
|
? `/api/festivals?sido=${encodeURIComponent(region)}&limit=5`
|
||||||
|
: '/api/festivals?limit=5';
|
||||||
|
|
||||||
|
const festivalRes = await fetch(festivalUrl);
|
||||||
|
if (festivalRes.ok) {
|
||||||
|
const data = await festivalRes.json();
|
||||||
|
setUpcomingFestivals(data.festivals || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch festivals:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingFestivals(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
fetchFestivals();
|
||||||
|
} else {
|
||||||
|
setLoadingFestivals(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
// Format festival date
|
||||||
|
const formatFestivalDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
return `${dateStr.slice(4, 6)}.${dateStr.slice(6, 8)}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Stats calculations
|
// Stats calculations
|
||||||
const totalVideos = libraryItems.length;
|
const totalVideos = libraryItems.length;
|
||||||
|
|
@ -64,6 +133,43 @@ const DashboardView: React.FC<DashboardViewProps> = ({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Festival Alert Banner - Show when there are ongoing festivals */}
|
||||||
|
{upcomingFestivals.length > 0 && upcomingFestivals.some(f => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
|
const startDate = f.eventstartdate || '';
|
||||||
|
const endDate = f.eventenddate || '';
|
||||||
|
return (startDate && endDate && today >= startDate && today <= endDate);
|
||||||
|
}) && (
|
||||||
|
<Card className="bg-gradient-to-r from-orange-500 via-pink-500 to-purple-500 text-white border-0 overflow-hidden relative">
|
||||||
|
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiNmZmYiIGZpbGwtb3BhY2l0eT0iMC4xIj48cGF0aCBkPSJNMzYgMzRoLTJ2LTRoMnY0em0wLThoLTJ2LTRoMnY0em0tOCA4aC0ydi00aDJ2NHptMC04aC0ydi00aDJ2NHoiLz48L2c+PC9nPjwvc3ZnPg==')] opacity-30" />
|
||||||
|
<CardContent className="p-6 relative">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-white/20 backdrop-blur-sm flex items-center justify-center">
|
||||||
|
<PartyPopper className="w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
{userRegion ? `${userRegion} 지역 축제 진행 중!` : '지금 축제가 진행 중이에요!'}
|
||||||
|
<span className="animate-pulse">🎉</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-white/80 text-sm">
|
||||||
|
축제를 홍보하는 영상을 만들어 펜션 예약률을 높여보세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onCreateNew}
|
||||||
|
className="bg-white text-orange-600 hover:bg-white/90 font-bold shadow-lg"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
축제 영상 만들기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -166,6 +272,101 @@ const DashboardView: React.FC<DashboardViewProps> = ({
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming Festivals Widget */}
|
||||||
|
<Card className="border-orange-500/30 bg-gradient-to-r from-orange-500/5 to-transparent">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-orange-500/10 flex items-center justify-center">
|
||||||
|
<PartyPopper className="w-4 h-4 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{userRegion ? `${userRegion} 지역 축제` : '다가오는 축제'}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
콘텐츠 제작에 활용할 수 있는 축제 정보
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onViewFestivals && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onViewFestivals} className="text-orange-600 hover:text-orange-700 hover:bg-orange-500/10">
|
||||||
|
전체 보기
|
||||||
|
<ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loadingFestivals ? (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-orange-500 mr-2" />
|
||||||
|
<span className="text-sm text-muted-foreground">축제 정보 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : upcomingFestivals.length === 0 ? (
|
||||||
|
<div className="text-center py-6 text-muted-foreground">
|
||||||
|
<PartyPopper className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">예정된 축제가 없습니다</p>
|
||||||
|
{onViewFestivals && (
|
||||||
|
<Button variant="link" size="sm" onClick={onViewFestivals} className="text-orange-600 mt-2">
|
||||||
|
전국 축제 둘러보기
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{upcomingFestivals.slice(0, 4).map((festival) => (
|
||||||
|
<div
|
||||||
|
key={festival.id}
|
||||||
|
className="flex items-center gap-3 p-2 rounded-lg hover:bg-orange-500/5 transition-colors cursor-pointer group"
|
||||||
|
onClick={onViewFestivals}
|
||||||
|
>
|
||||||
|
{festival.firstimage ? (
|
||||||
|
<img
|
||||||
|
src={festival.firstimage}
|
||||||
|
alt={festival.title}
|
||||||
|
className="w-12 h-12 rounded-lg object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
|
||||||
|
<PartyPopper className="w-5 h-5 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-sm truncate group-hover:text-orange-600 transition-colors">
|
||||||
|
{festival.title}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{festival.eventstartdate && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{formatFestivalDate(festival.eventstartdate)} ~ {formatFestivalDate(festival.eventenddate)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="shrink-0 text-orange-600 border-orange-500/30">
|
||||||
|
<MapPin className="w-3 h-3 mr-1" />
|
||||||
|
{festival.addr1?.split(' ').slice(0, 2).join(' ') || ''}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{upcomingFestivals.length > 4 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full text-orange-600 hover:text-orange-700 hover:bg-orange-500/10"
|
||||||
|
size="sm"
|
||||||
|
onClick={onViewFestivals}
|
||||||
|
>
|
||||||
|
+{upcomingFestivals.length - 4}개 더 보기
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Recent Projects */}
|
{/* Recent Projects */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,675 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
|
import { Button } from '../components/ui/button';
|
||||||
|
import { Input } from '../components/ui/input';
|
||||||
|
import { Badge } from '../components/ui/badge';
|
||||||
|
import { Separator } from '../components/ui/separator';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '../components/ui/dialog';
|
||||||
|
import {
|
||||||
|
PartyPopper,
|
||||||
|
Search,
|
||||||
|
MapPin,
|
||||||
|
Calendar,
|
||||||
|
Phone,
|
||||||
|
ExternalLink,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
Home,
|
||||||
|
Navigation,
|
||||||
|
Filter,
|
||||||
|
X,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Sparkles,
|
||||||
|
Wand2,
|
||||||
|
Video,
|
||||||
|
Grid3X3,
|
||||||
|
CalendarDays
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
interface Festival {
|
||||||
|
id: number;
|
||||||
|
content_id: string;
|
||||||
|
title: string;
|
||||||
|
addr1: string;
|
||||||
|
addr2: string;
|
||||||
|
sido: string;
|
||||||
|
sigungu: string;
|
||||||
|
mapx: number;
|
||||||
|
mapy: number;
|
||||||
|
event_start_date: string;
|
||||||
|
event_end_date: string;
|
||||||
|
first_image: string;
|
||||||
|
first_image2: string;
|
||||||
|
tel: string;
|
||||||
|
homepage: string;
|
||||||
|
overview: string;
|
||||||
|
view_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pension {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
sido: string;
|
||||||
|
tel: string;
|
||||||
|
thumbnail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pagination {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REGIONS = [
|
||||||
|
{ code: '', name: '전체 지역' },
|
||||||
|
{ code: '서울', name: '서울' },
|
||||||
|
{ code: '부산', name: '부산' },
|
||||||
|
{ code: '대구', name: '대구' },
|
||||||
|
{ code: '인천', name: '인천' },
|
||||||
|
{ code: '광주', name: '광주' },
|
||||||
|
{ code: '대전', name: '대전' },
|
||||||
|
{ code: '울산', name: '울산' },
|
||||||
|
{ code: '세종', name: '세종' },
|
||||||
|
{ code: '경기', name: '경기' },
|
||||||
|
{ code: '강원', name: '강원' },
|
||||||
|
{ code: '충북', name: '충북' },
|
||||||
|
{ code: '충남', name: '충남' },
|
||||||
|
{ code: '전북', name: '전북' },
|
||||||
|
{ code: '전남', name: '전남' },
|
||||||
|
{ code: '경북', name: '경북' },
|
||||||
|
{ code: '경남', name: '경남' },
|
||||||
|
{ code: '제주', name: '제주' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface FestivalsViewProps {
|
||||||
|
onCreateWithFestival?: (festival: Festival) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FestivalsView: React.FC<FestivalsViewProps> = ({ onCreateWithFestival }) => {
|
||||||
|
const { token } = useAuth();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [festivals, setFestivals] = useState<Festival[]>([]);
|
||||||
|
const [pagination, setPagination] = useState<Pagination | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedFestival, setSelectedFestival] = useState<Festival | null>(null);
|
||||||
|
const [nearbyPensions, setNearbyPensions] = useState<Pension[]>([]);
|
||||||
|
const [loadingPensions, setLoadingPensions] = useState(false);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [selectedRegion, setSelectedRegion] = useState('');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'calendar'>('grid');
|
||||||
|
const [calendarMonth, setCalendarMonth] = useState(new Date());
|
||||||
|
|
||||||
|
// Fetch festivals
|
||||||
|
const fetchFestivals = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: currentPage.toString(),
|
||||||
|
limit: '12',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedRegion) params.append('sido', selectedRegion);
|
||||||
|
if (searchKeyword) params.append('keyword', searchKeyword);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/festivals?${params}`, {
|
||||||
|
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setFestivals(data.festivals);
|
||||||
|
setPagination(data.pagination);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch festivals:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, currentPage, selectedRegion, searchKeyword]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFestivals();
|
||||||
|
}, [fetchFestivals]);
|
||||||
|
|
||||||
|
// Fetch nearby pensions when festival selected
|
||||||
|
const fetchNearbyPensions = async (festivalId: number) => {
|
||||||
|
setLoadingPensions(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/festivals/${festivalId}/nearby-pensions?limit=6`, {
|
||||||
|
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setNearbyPensions(data.pensions);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch nearby pensions:', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingPensions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFestivalClick = (festival: Festival) => {
|
||||||
|
setSelectedFestival(festival);
|
||||||
|
setNearbyPensions([]);
|
||||||
|
fetchNearbyPensions(festival.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchFestivals();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr || dateStr.length !== 8) return dateStr;
|
||||||
|
return `${dateStr.slice(0, 4)}.${dateStr.slice(4, 6)}.${dateStr.slice(6, 8)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFestivalStatus = (startDate: string, endDate: string) => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
|
if (today < startDate) return { label: '예정', color: 'bg-blue-500' };
|
||||||
|
if (today > endDate) return { label: '종료', color: 'bg-gray-500' };
|
||||||
|
return { label: '진행중', color: 'bg-green-500' };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calendar helper functions
|
||||||
|
const getCalendarDays = () => {
|
||||||
|
const year = calendarMonth.getFullYear();
|
||||||
|
const month = calendarMonth.getMonth();
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
const startPadding = firstDay.getDay();
|
||||||
|
const days: (number | null)[] = [];
|
||||||
|
|
||||||
|
// Add padding for days before the first day
|
||||||
|
for (let i = 0; i < startPadding; i++) {
|
||||||
|
days.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add days of the month
|
||||||
|
for (let d = 1; d <= lastDay.getDate(); d++) {
|
||||||
|
days.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFestivalsForDay = (day: number) => {
|
||||||
|
if (!day) return [];
|
||||||
|
const year = calendarMonth.getFullYear();
|
||||||
|
const month = calendarMonth.getMonth() + 1;
|
||||||
|
const dateStr = `${year}${month.toString().padStart(2, '0')}${day.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
return festivals.filter(f => {
|
||||||
|
const start = f.event_start_date;
|
||||||
|
const end = f.event_end_date;
|
||||||
|
return dateStr >= start && dateStr <= end;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateMonth = (direction: number) => {
|
||||||
|
setCalendarMonth(prev => new Date(prev.getFullYear(), prev.getMonth() + direction, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-auto p-6 bg-background">
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<PartyPopper className="w-7 h-7 text-orange-500" />
|
||||||
|
전국 축제 정보
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
전국 각지의 축제를 찾아보고 근처 펜션을 확인하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<div className="flex items-center bg-muted rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-md transition-colors",
|
||||||
|
viewMode === 'grid' ? "bg-background shadow-sm" : "hover:bg-background/50"
|
||||||
|
)}
|
||||||
|
title="그리드 뷰"
|
||||||
|
>
|
||||||
|
<Grid3X3 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('calendar')}
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-md transition-colors",
|
||||||
|
viewMode === 'calendar' ? "bg-background shadow-sm" : "hover:bg-background/50"
|
||||||
|
)}
|
||||||
|
title="캘린더 뷰"
|
||||||
|
>
|
||||||
|
<CalendarDays className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{pagination && (
|
||||||
|
<Badge variant="secondary" className="text-sm">
|
||||||
|
총 {pagination.total}개 축제
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="축제명, 지역으로 검색..."
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedRegion}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedRegion(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 rounded-md border bg-background text-sm"
|
||||||
|
>
|
||||||
|
{REGIONS.map((region) => (
|
||||||
|
<option key={region.code} value={region.code}>
|
||||||
|
{region.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Button type="submit">
|
||||||
|
<Search className="w-4 h-4 mr-2" />
|
||||||
|
검색
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : festivals.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<PartyPopper className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">축제를 찾을 수 없습니다</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
다른 검색어나 지역으로 검색해 보세요
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Calendar View */}
|
||||||
|
{viewMode === 'calendar' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigateMonth(-1)}>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<h3 className="text-lg font-bold">
|
||||||
|
{calendarMonth.getFullYear()}년 {calendarMonth.getMonth() + 1}월
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigateMonth(1)}>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Day headers */}
|
||||||
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||||
|
{['일', '월', '화', '수', '목', '금', '토'].map((day, i) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className={cn(
|
||||||
|
"text-center text-sm font-medium py-2",
|
||||||
|
i === 0 && "text-red-500",
|
||||||
|
i === 6 && "text-blue-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar days */}
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{getCalendarDays().map((day, idx) => {
|
||||||
|
const dayFestivals = day ? getFestivalsForDay(day) : [];
|
||||||
|
const isToday = day && new Date().getDate() === day &&
|
||||||
|
new Date().getMonth() === calendarMonth.getMonth() &&
|
||||||
|
new Date().getFullYear() === calendarMonth.getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
"min-h-[80px] p-1 border rounded-lg",
|
||||||
|
day ? "bg-card hover:bg-muted/50 cursor-pointer" : "bg-muted/30",
|
||||||
|
isToday && "ring-2 ring-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day && (
|
||||||
|
<>
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
idx % 7 === 0 && "text-red-500",
|
||||||
|
idx % 7 === 6 && "text-blue-500"
|
||||||
|
)}>
|
||||||
|
{day}
|
||||||
|
</span>
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{dayFestivals.slice(0, 2).map(f => (
|
||||||
|
<div
|
||||||
|
key={f.id}
|
||||||
|
className="text-[10px] bg-orange-500/20 text-orange-700 dark:text-orange-400 rounded px-1 py-0.5 truncate cursor-pointer hover:bg-orange-500/30"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleFestivalClick(f);
|
||||||
|
}}
|
||||||
|
title={f.title}
|
||||||
|
>
|
||||||
|
{f.title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{dayFestivals.length > 2 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
+{dayFestivals.length - 2}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Festivals Grid */}
|
||||||
|
{viewMode === 'grid' && (
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{festivals.map((festival) => {
|
||||||
|
const status = getFestivalStatus(festival.event_start_date, festival.event_end_date);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={festival.id}
|
||||||
|
className="overflow-hidden cursor-pointer hover:shadow-lg transition-all group"
|
||||||
|
onClick={() => handleFestivalClick(festival)}
|
||||||
|
>
|
||||||
|
{/* Image */}
|
||||||
|
<div className="aspect-[16/10] bg-muted relative overflow-hidden">
|
||||||
|
{festival.first_image ? (
|
||||||
|
<img
|
||||||
|
src={festival.first_image}
|
||||||
|
alt={festival.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<ImageIcon className="w-12 h-12 text-muted-foreground/30" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Badge className={cn("absolute top-2 right-2", status.color)}>
|
||||||
|
{status.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="font-bold text-lg line-clamp-1 mb-2">
|
||||||
|
{festival.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 text-sm text-muted-foreground">
|
||||||
|
<p className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="line-clamp-1">{festival.addr1}</span>
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{formatDate(festival.event_start_date)} ~ {formatDate(festival.event_end_date)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{festival.sido && (
|
||||||
|
<Badge variant="outline" className="mt-3">
|
||||||
|
{festival.sido}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination - only show in grid view */}
|
||||||
|
{viewMode === 'grid' && pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="text-sm text-muted-foreground px-4">
|
||||||
|
{currentPage} / {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(p => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
disabled={currentPage === pagination.totalPages}
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Festival Detail Dialog */}
|
||||||
|
<Dialog open={!!selectedFestival} onOpenChange={() => setSelectedFestival(null)}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
{selectedFestival && (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl flex items-center gap-2">
|
||||||
|
<PartyPopper className="w-6 h-6 text-orange-500" />
|
||||||
|
{selectedFestival.title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{selectedFestival.sido} {selectedFestival.sigungu}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Image */}
|
||||||
|
{selectedFestival.first_image && (
|
||||||
|
<div className="aspect-video rounded-lg overflow-hidden bg-muted">
|
||||||
|
<img
|
||||||
|
src={selectedFestival.first_image}
|
||||||
|
alt={selectedFestival.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Calendar className="w-5 h-5 text-primary mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">행사 기간</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatDate(selectedFestival.event_start_date)} ~ {formatDate(selectedFestival.event_end_date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<MapPin className="w-5 h-5 text-primary mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">장소</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{selectedFestival.addr1} {selectedFestival.addr2}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedFestival.tel && (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Phone className="w-5 h-5 text-primary mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">연락처</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{selectedFestival.tel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map Link */}
|
||||||
|
{selectedFestival.mapx && selectedFestival.mapy && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<a
|
||||||
|
href={`https://map.kakao.com/link/map/${selectedFestival.title},${selectedFestival.mapy},${selectedFestival.mapx}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-2 p-3 rounded-lg bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Navigation className="w-5 h-5" />
|
||||||
|
카카오맵에서 보기
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`https://map.naver.com/v5/search/${encodeURIComponent(selectedFestival.addr1)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-2 p-3 rounded-lg bg-green-500/10 text-green-600 hover:bg-green-500/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Navigation className="w-5 h-5" />
|
||||||
|
네이버지도에서 보기
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Content Button */}
|
||||||
|
{onCreateWithFestival && (
|
||||||
|
<div className="bg-gradient-to-r from-orange-500/10 via-pink-500/10 to-purple-500/10 rounded-xl p-4">
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-orange-500 to-pink-500 flex items-center justify-center">
|
||||||
|
<Wand2 className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">이 축제로 콘텐츠 만들기</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
축제 정보가 자동으로 가사와 영상에 반영됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onCreateWithFestival(selectedFestival);
|
||||||
|
setSelectedFestival(null);
|
||||||
|
}}
|
||||||
|
className="bg-gradient-to-r from-orange-500 to-pink-500 hover:from-orange-600 hover:to-pink-600 text-white"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
콘텐츠 생성하기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Nearby Pensions */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg flex items-center gap-2 mb-4">
|
||||||
|
<Home className="w-5 h-5 text-green-500" />
|
||||||
|
근처 펜션
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{loadingPensions ? (
|
||||||
|
<div className="flex items-center justify-center h-24">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : nearbyPensions.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-center py-6">
|
||||||
|
근처에 등록된 펜션이 없습니다
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
{nearbyPensions.map((pension) => (
|
||||||
|
<Card key={pension.id} className="p-3">
|
||||||
|
<div className="aspect-[4/3] rounded-md bg-muted mb-2 overflow-hidden">
|
||||||
|
{pension.thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={pension.thumbnail}
|
||||||
|
alt={pension.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<Home className="w-8 h-8 text-muted-foreground/30" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-medium text-sm line-clamp-1">{pension.name}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-1">
|
||||||
|
{pension.address}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FestivalsView;
|
||||||
|
|
@ -23,7 +23,10 @@ import {
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Eye,
|
Eye,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
X
|
X,
|
||||||
|
Youtube,
|
||||||
|
Instagram,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -52,10 +55,137 @@ const LibraryView: React.FC<LibraryViewProps> = ({
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
const [showPlayer, setShowPlayer] = useState(false);
|
const [showPlayer, setShowPlayer] = useState(false);
|
||||||
|
|
||||||
|
// 업로드 상태
|
||||||
|
const [uploadingId, setUploadingId] = useState<number | null>(null);
|
||||||
|
const [uploadPlatform, setUploadPlatform] = useState<'youtube' | 'instagram' | null>(null);
|
||||||
|
|
||||||
const filteredItems = items.filter(item =>
|
const filteredItems = items.filter(item =>
|
||||||
item.businessName.toLowerCase().includes(searchQuery.toLowerCase())
|
item.businessName.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// YouTube 업로드
|
||||||
|
const handleYoutubeUpload = async (e: React.MouseEvent, asset: GeneratedAssets) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!asset.id || !asset.finalVideoPath) {
|
||||||
|
alert('업로드할 영상이 없습니다. 먼저 영상을 생성해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadingId(asset.id);
|
||||||
|
setUploadPlatform('youtube');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// YouTube 연결 상태 확인
|
||||||
|
const connRes = await fetch('/api/youtube/connection', {
|
||||||
|
headers: { 'Authorization': token ? `Bearer ${token}` : '' }
|
||||||
|
});
|
||||||
|
const connData = await connRes.json();
|
||||||
|
|
||||||
|
if (!connData.connected) {
|
||||||
|
if (confirm('YouTube 계정이 연결되지 않았습니다. 설정 페이지를 새 탭에서 여시겠습니까?')) {
|
||||||
|
window.open('/settings', '_blank');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 업로드 실행
|
||||||
|
const response = await fetch('/api/youtube/my-upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': token ? `Bearer ${token}` : ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
videoPath: asset.finalVideoPath,
|
||||||
|
seoData: {
|
||||||
|
title: `${asset.businessName} - AI Marketing Video`,
|
||||||
|
description: asset.description || asset.adCopy?.join('\n') || '',
|
||||||
|
tags: ['펜션', '숙소', '여행', 'AI마케팅', 'CastAD']
|
||||||
|
},
|
||||||
|
historyId: asset.id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'YouTube 업로드 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(`YouTube 업로드 성공!\n${data.youtubeUrl || data.url}`);
|
||||||
|
if (data.youtubeUrl || data.url) {
|
||||||
|
window.open(data.youtubeUrl || data.url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('YouTube 업로드 오류:', error);
|
||||||
|
alert(`YouTube 업로드 실패: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setUploadingId(null);
|
||||||
|
setUploadPlatform(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Instagram 업로드
|
||||||
|
const handleInstagramUpload = async (e: React.MouseEvent, asset: GeneratedAssets) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!asset.id || !asset.finalVideoPath) {
|
||||||
|
alert('업로드할 영상이 없습니다. 먼저 영상을 생성해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadingId(asset.id);
|
||||||
|
setUploadPlatform('instagram');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Instagram 연결 상태 확인
|
||||||
|
const statusRes = await fetch('/api/instagram/status', {
|
||||||
|
headers: { 'Authorization': token ? `Bearer ${token}` : '' }
|
||||||
|
});
|
||||||
|
const statusData = await statusRes.json();
|
||||||
|
|
||||||
|
if (!statusData.connected) {
|
||||||
|
if (confirm('Instagram 계정이 연결되지 않았습니다. 설정 페이지를 새 탭에서 여시겠습니까?')) {
|
||||||
|
window.open('/settings', '_blank');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 캡션 및 해시태그 생성
|
||||||
|
const caption = asset.adCopy?.join('\n') || asset.businessName || '';
|
||||||
|
const hashtags = `#${asset.businessName?.replace(/\s+/g, '')} #펜션 #숙소 #여행 #힐링 #휴가 #AI마케팅 #CaStAD`;
|
||||||
|
|
||||||
|
// 업로드 실행
|
||||||
|
const response = await fetch('/api/instagram/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': token ? `Bearer ${token}` : ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
history_id: asset.id,
|
||||||
|
caption: caption,
|
||||||
|
hashtags: hashtags
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Instagram 업로드 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Instagram Reels 업로드 성공!');
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Instagram 업로드 오류:', error);
|
||||||
|
alert(`Instagram 업로드 실패: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setUploadingId(null);
|
||||||
|
setUploadPlatform(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDirectDownload = async (e: React.MouseEvent, url: string, filename: string) => {
|
const handleDirectDownload = async (e: React.MouseEvent, url: string, filename: string) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -244,12 +374,39 @@ const LibraryView: React.FC<LibraryViewProps> = ({
|
||||||
{t('libViewDetail')}
|
{t('libViewDetail')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{asset.finalVideoPath && (
|
{asset.finalVideoPath && (
|
||||||
|
<>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => handleDirectDownload(e as any, asset.finalVideoPath!, `CastAD_${asset.businessName}.mp4`)}
|
onClick={(e) => handleDirectDownload(e as any, asset.finalVideoPath!, `CastAD_${asset.businessName}.mp4`)}
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
{t('libDownload')}
|
{t('libDownload')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => handleYoutubeUpload(e, asset)}
|
||||||
|
disabled={uploadingId === asset.id}
|
||||||
|
className="text-red-600"
|
||||||
|
>
|
||||||
|
{uploadingId === asset.id && uploadPlatform === 'youtube' ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Youtube className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
YouTube 업로드
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => handleInstagramUpload(e, asset)}
|
||||||
|
disabled={uploadingId === asset.id}
|
||||||
|
className="text-pink-600"
|
||||||
|
>
|
||||||
|
{uploadingId === asset.id && uploadPlatform === 'instagram' ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Instagram className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Instagram 업로드
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-destructive">
|
<DropdownMenuItem className="text-destructive">
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -37,9 +37,25 @@ import {
|
||||||
Video,
|
Video,
|
||||||
Clock,
|
Clock,
|
||||||
Heart,
|
Heart,
|
||||||
Users
|
Users,
|
||||||
|
PartyPopper,
|
||||||
|
Calendar,
|
||||||
|
MapPin,
|
||||||
|
ChevronRight,
|
||||||
|
Zap,
|
||||||
|
Settings2,
|
||||||
|
Play,
|
||||||
|
Image as ImageIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PensionProfile, PlanType, PLAN_CONFIG } from '../../types';
|
import { Switch } from '../components/ui/switch';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '../components/ui/select';
|
||||||
|
import { PensionProfile, PlanType, PLAN_CONFIG, NearbyFestival } from '../../types';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
interface UserPlan {
|
interface UserPlan {
|
||||||
|
|
@ -74,6 +90,19 @@ interface AnalyticsSummary {
|
||||||
pensions: PensionSummary[];
|
pensions: PensionSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AutoGenerationSettings {
|
||||||
|
pension_id: number;
|
||||||
|
enabled: boolean;
|
||||||
|
generation_time: string;
|
||||||
|
image_mode: 'priority_first' | 'random' | 'all';
|
||||||
|
random_count: number;
|
||||||
|
auto_upload_youtube: boolean;
|
||||||
|
auto_upload_instagram: boolean;
|
||||||
|
auto_upload_tiktok: boolean;
|
||||||
|
last_generated_at: string | null;
|
||||||
|
next_scheduled_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const PensionsView: React.FC = () => {
|
const PensionsView: React.FC = () => {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -90,6 +119,16 @@ const PensionsView: React.FC = () => {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Nearby festivals state (per pension)
|
||||||
|
const [pensionFestivals, setPensionFestivals] = useState<Record<number, NearbyFestival[]>>({});
|
||||||
|
const [loadingFestivals, setLoadingFestivals] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
// Auto-generation state
|
||||||
|
const [autoGenSettings, setAutoGenSettings] = useState<Record<number, AutoGenerationSettings>>({});
|
||||||
|
const [isAutoGenDialogOpen, setIsAutoGenDialogOpen] = useState(false);
|
||||||
|
const [autoGenFormData, setAutoGenFormData] = useState<AutoGenerationSettings | null>(null);
|
||||||
|
const [savingAutoGen, setSavingAutoGen] = useState(false);
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
brand_name: '',
|
brand_name: '',
|
||||||
|
|
@ -106,6 +145,186 @@ const PensionsView: React.FC = () => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
|
// Fetch nearby festivals and auto-gen settings for all pensions after pensions are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (pensions.length > 0) {
|
||||||
|
pensions.forEach(pension => {
|
||||||
|
fetchNearbyFestivals(pension);
|
||||||
|
fetchAutoGenSettings(pension.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [pensions]);
|
||||||
|
|
||||||
|
// Fetch nearby festivals for a specific pension
|
||||||
|
const fetchNearbyFestivals = async (pension: PensionProfile) => {
|
||||||
|
if (!pension.region && !pension.address) return;
|
||||||
|
if (pensionFestivals[pension.id]) return; // Already loaded
|
||||||
|
|
||||||
|
setLoadingFestivals(prev => ({ ...prev, [pension.id]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract sido from region or address
|
||||||
|
let sido = pension.region || '';
|
||||||
|
if (!sido && pension.address) {
|
||||||
|
const parts = pension.address.split(' ');
|
||||||
|
if (parts.length > 0) {
|
||||||
|
sido = parts[0].replace(/도|시|특별시|광역시|특별자치시|특별자치도/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/festivals?sido=${encodeURIComponent(sido)}&limit=3`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPensionFestivals(prev => ({
|
||||||
|
...prev,
|
||||||
|
[pension.id]: data.festivals || []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch festivals for pension:', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingFestivals(prev => ({ ...prev, [pension.id]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format festival date
|
||||||
|
const formatFestivalDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
return `${dateStr.slice(4, 6)}.${dateStr.slice(6, 8)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch auto-generation settings for a pension
|
||||||
|
const fetchAutoGenSettings = async (pensionId: number) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/profile/pension/${pensionId}/auto-generation`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setAutoGenSettings(prev => ({ ...prev, [pensionId]: data }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch auto-gen settings:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open auto-generation settings dialog
|
||||||
|
const handleOpenAutoGenDialog = async (pension: PensionProfile) => {
|
||||||
|
setSelectedPension(pension);
|
||||||
|
|
||||||
|
// 기존 설정 가져오기 또는 기본값 사용
|
||||||
|
let settings = autoGenSettings[pension.id];
|
||||||
|
if (!settings) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/profile/pension/${pension.id}/auto-generation`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
settings = await res.json();
|
||||||
|
setAutoGenSettings(prev => ({ ...prev, [pension.id]: settings! }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch auto-gen settings:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAutoGenFormData(settings || {
|
||||||
|
pension_id: pension.id,
|
||||||
|
enabled: false,
|
||||||
|
generation_time: '09:00',
|
||||||
|
image_mode: 'priority_first',
|
||||||
|
random_count: 10,
|
||||||
|
auto_upload_youtube: true,
|
||||||
|
auto_upload_instagram: false,
|
||||||
|
auto_upload_tiktok: false,
|
||||||
|
last_generated_at: null,
|
||||||
|
next_scheduled_at: null
|
||||||
|
});
|
||||||
|
setIsAutoGenDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save auto-generation settings
|
||||||
|
const handleSaveAutoGenSettings = async () => {
|
||||||
|
if (!selectedPension || !autoGenFormData) return;
|
||||||
|
|
||||||
|
setSavingAutoGen(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/profile/pension/${selectedPension.id}/auto-generation`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(autoGenFormData)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setAutoGenSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
[selectedPension.id]: { ...autoGenFormData, next_scheduled_at: data.next_scheduled_at }
|
||||||
|
}));
|
||||||
|
setMessage({ type: 'success', text: autoGenFormData.enabled ? '자동 생성이 활성화되었습니다.' : '자동 생성 설정이 저장되었습니다.' });
|
||||||
|
setIsAutoGenDialogOpen(false);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setMessage({ type: 'error', text: err.message });
|
||||||
|
} finally {
|
||||||
|
setSavingAutoGen(false);
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Quick toggle auto-generation
|
||||||
|
const handleToggleAutoGen = async (pension: PensionProfile) => {
|
||||||
|
const currentSettings = autoGenSettings[pension.id];
|
||||||
|
const newEnabled = !currentSettings?.enabled;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/profile/pension/${pension.id}/auto-generation`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...(currentSettings || {
|
||||||
|
generation_time: '09:00',
|
||||||
|
image_mode: 'priority_first',
|
||||||
|
random_count: 10,
|
||||||
|
auto_upload_youtube: true,
|
||||||
|
auto_upload_instagram: false,
|
||||||
|
auto_upload_tiktok: false
|
||||||
|
}),
|
||||||
|
enabled: newEnabled
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setAutoGenSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
[pension.id]: {
|
||||||
|
...(prev[pension.id] || {}),
|
||||||
|
enabled: newEnabled,
|
||||||
|
next_scheduled_at: data.next_scheduled_at
|
||||||
|
} as AutoGenerationSettings
|
||||||
|
}));
|
||||||
|
setMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: newEnabled ? '일일 자동 생성이 활성화되었습니다.' : '일일 자동 생성이 비활성화되었습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to toggle auto-gen:', err);
|
||||||
|
}
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -499,6 +718,45 @@ const PensionsView: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Auto Generation Status */}
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center justify-between p-3 rounded-lg",
|
||||||
|
autoGenSettings[pension.id]?.enabled
|
||||||
|
? "bg-amber-500/10 border border-amber-500/30"
|
||||||
|
: "bg-muted/50"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className={cn(
|
||||||
|
"w-5 h-5",
|
||||||
|
autoGenSettings[pension.id]?.enabled ? "text-amber-500" : "text-muted-foreground"
|
||||||
|
)} />
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{autoGenSettings[pension.id]?.enabled ? '자동 생성 ON' : '자동 생성 OFF'}
|
||||||
|
</span>
|
||||||
|
{autoGenSettings[pension.id]?.enabled && autoGenSettings[pension.id]?.generation_time && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
매일 {autoGenSettings[pension.id].generation_time}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Switch
|
||||||
|
checked={autoGenSettings[pension.id]?.enabled || false}
|
||||||
|
onCheckedChange={() => handleToggleAutoGen(pension)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleOpenAutoGenDialog(pension)}
|
||||||
|
className="w-8 h-8 p-0"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Analytics Preview */}
|
{/* Analytics Preview */}
|
||||||
{analytics && analytics.total_videos > 0 && (
|
{analytics && analytics.total_videos > 0 && (
|
||||||
<div className="grid grid-cols-3 gap-2 text-center">
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
|
|
@ -517,6 +775,42 @@ const PensionsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Nearby Festivals */}
|
||||||
|
{(pensionFestivals[pension.id]?.length > 0 || loadingFestivals[pension.id]) && (
|
||||||
|
<div className="bg-orange-500/5 rounded-lg p-3 border border-orange-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<PartyPopper className="w-4 h-4 text-orange-500" />
|
||||||
|
<span className="text-sm font-medium">근처 축제</span>
|
||||||
|
</div>
|
||||||
|
{loadingFestivals[pension.id] ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
불러오는 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{pensionFestivals[pension.id]?.slice(0, 2).map((festival) => (
|
||||||
|
<div key={festival.id} className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-medium text-foreground truncate max-w-[150px]">
|
||||||
|
{festival.title}
|
||||||
|
</span>
|
||||||
|
{festival.eventstartdate && (
|
||||||
|
<span className="text-orange-600 shrink-0">
|
||||||
|
{formatFestivalDate(festival.eventstartdate)}~
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(pensionFestivals[pension.id]?.length || 0) > 2 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
+{pensionFestivals[pension.id].length - 2}개 더
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Links */}
|
{/* Links */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{pension.homepage_url && (
|
{pension.homepage_url && (
|
||||||
|
|
@ -778,6 +1072,202 @@ const PensionsView: React.FC = () => {
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Auto Generation Settings Dialog */}
|
||||||
|
<Dialog open={isAutoGenDialogOpen} onOpenChange={setIsAutoGenDialogOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Zap className="w-5 h-5 text-amber-500" />
|
||||||
|
일일 자동 생성 설정
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{selectedPension?.brand_name} - 매일 자동으로 영상을 생성하고 업로드합니다
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{autoGenFormData && (
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{/* Enable Toggle */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-10 rounded-full flex items-center justify-center",
|
||||||
|
autoGenFormData.enabled ? "bg-amber-500/20" : "bg-muted"
|
||||||
|
)}>
|
||||||
|
<Zap className={cn(
|
||||||
|
"w-5 h-5",
|
||||||
|
autoGenFormData.enabled ? "text-amber-500" : "text-muted-foreground"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">자동 생성</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{autoGenFormData.enabled ? '매일 영상 자동 생성' : '비활성화됨'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={autoGenFormData.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setAutoGenFormData(prev => prev ? { ...prev, enabled: checked } : prev)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings (shown when enabled) */}
|
||||||
|
{autoGenFormData.enabled && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Generation Time */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
생성 시간
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={autoGenFormData.generation_time}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setAutoGenFormData(prev => prev ? { ...prev, generation_time: value } : prev)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="06:00">오전 6시</SelectItem>
|
||||||
|
<SelectItem value="09:00">오전 9시</SelectItem>
|
||||||
|
<SelectItem value="12:00">정오 12시</SelectItem>
|
||||||
|
<SelectItem value="15:00">오후 3시</SelectItem>
|
||||||
|
<SelectItem value="18:00">오후 6시</SelectItem>
|
||||||
|
<SelectItem value="21:00">오후 9시</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image Mode */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="w-4 h-4" />
|
||||||
|
이미지 선택 방식
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={autoGenFormData.image_mode}
|
||||||
|
onValueChange={(value: 'priority_first' | 'random' | 'all') =>
|
||||||
|
setAutoGenFormData(prev => prev ? { ...prev, image_mode: value } : prev)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="priority_first">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Star className="w-3 h-3 text-amber-500" />
|
||||||
|
수동 업로드 우선
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="random">랜덤 선택</SelectItem>
|
||||||
|
<SelectItem value="all">모든 이미지</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{autoGenFormData.image_mode === 'priority_first'
|
||||||
|
? '수동으로 업로드한 이미지를 우선 사용합니다'
|
||||||
|
: autoGenFormData.image_mode === 'random'
|
||||||
|
? '저장된 이미지 중 랜덤으로 선택합니다'
|
||||||
|
: '모든 이미지를 순환하며 사용합니다'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image Count (for random mode) */}
|
||||||
|
{autoGenFormData.image_mode !== 'all' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>사용할 이미지 수</Label>
|
||||||
|
<Select
|
||||||
|
value={String(autoGenFormData.random_count)}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setAutoGenFormData(prev => prev ? { ...prev, random_count: parseInt(value) } : prev)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">5장</SelectItem>
|
||||||
|
<SelectItem value="10">10장</SelectItem>
|
||||||
|
<SelectItem value="15">15장</SelectItem>
|
||||||
|
<SelectItem value="20">20장</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Auto Upload Options */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>자동 업로드</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between p-2 rounded-lg bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Youtube className="w-4 h-4 text-red-500" />
|
||||||
|
<span className="text-sm">YouTube</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={autoGenFormData.auto_upload_youtube}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setAutoGenFormData(prev => prev ? { ...prev, auto_upload_youtube: checked } : prev)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-2 rounded-lg bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm">Instagram</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={autoGenFormData.auto_upload_instagram}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setAutoGenFormData(prev => prev ? { ...prev, auto_upload_instagram: checked } : prev)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next Schedule Info */}
|
||||||
|
{autoGenFormData.next_scheduled_at && (
|
||||||
|
<div className="p-3 bg-amber-500/10 rounded-lg border border-amber-500/30">
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="font-medium">다음 예약:</span>{' '}
|
||||||
|
{new Date(autoGenFormData.next_scheduled_at).toLocaleString('ko-KR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsAutoGenDialogOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveAutoGenSettings}
|
||||||
|
disabled={savingAutoGen}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{savingAutoGen && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { useUserLevel, LEVEL_INFO } from '../contexts/UserLevelContext';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import LevelSelector from '../components/settings/LevelSelector';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
import { Button } from '../components/ui/button';
|
import { Button } from '../components/ui/button';
|
||||||
import { Input } from '../components/ui/input';
|
import { Input } from '../components/ui/input';
|
||||||
|
|
@ -48,6 +50,7 @@ import {
|
||||||
Save,
|
Save,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Sliders,
|
||||||
Edit2,
|
Edit2,
|
||||||
Star,
|
Star,
|
||||||
StarOff
|
StarOff
|
||||||
|
|
@ -625,7 +628,11 @@ const SettingsView: React.FC = () => {
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
|
<TabsTrigger value="level" className="flex items-center gap-2">
|
||||||
|
<Sliders className="w-4 h-4" />
|
||||||
|
사용 모드
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="pension" className="flex items-center gap-2">
|
<TabsTrigger value="pension" className="flex items-center gap-2">
|
||||||
<Building2 className="w-4 h-4" />
|
<Building2 className="w-4 h-4" />
|
||||||
{t('settingsPensionTab')} ({pensions.length})
|
{t('settingsPensionTab')} ({pensions.length})
|
||||||
|
|
@ -640,6 +647,24 @@ const SettingsView: React.FC = () => {
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Level Selection Tab */}
|
||||||
|
<TabsContent value="level" className="space-y-6 mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Sliders className="w-5 h-5" />
|
||||||
|
사용 모드 선택
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
나에게 맞는 사용 모드를 선택하세요. 모드에 따라 메뉴와 옵션이 달라집니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<LevelSelector />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* Pension Profile Tab */}
|
{/* Pension Profile Tab */}
|
||||||
<TabsContent value="pension" className="space-y-6 mt-6">
|
<TabsContent value="pension" className="space-y-6 mt-6">
|
||||||
{/* Add New Pension Button */}
|
{/* Add New Pension Button */}
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,9 @@ if lsof -i:$INSTAGRAM_SERVICE_PORT >/dev/null 2>&1; then
|
||||||
else
|
else
|
||||||
if [ -f "server/instagram/instagram_service.py" ]; then
|
if [ -f "server/instagram/instagram_service.py" ]; then
|
||||||
log "Instagram 서비스 시작 중..."
|
log "Instagram 서비스 시작 중..."
|
||||||
|
# Python 캐시 삭제 (코드 변경 반영)
|
||||||
|
find server/instagram -name "*.pyc" -delete 2>/dev/null
|
||||||
|
find server/instagram -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null
|
||||||
# Python 의존성 확인
|
# Python 의존성 확인
|
||||||
if ! python3 -c "import instagrapi" 2>/dev/null; then
|
if ! python3 -c "import instagrapi" 2>/dev/null; then
|
||||||
warn "Instagram Python 의존성 설치 중..."
|
warn "Instagram Python 의존성 설치 중..."
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
131
types.ts
131
types.ts
|
|
@ -1,4 +1,49 @@
|
||||||
|
|
||||||
|
// 사용자 경험 레벨
|
||||||
|
export type UserLevel = 'beginner' | 'intermediate' | 'pro';
|
||||||
|
|
||||||
|
// 레벨별 기능 플래그
|
||||||
|
export interface FeatureFlags {
|
||||||
|
// 옵션 선택기 표시
|
||||||
|
showLanguageSelector: boolean;
|
||||||
|
showMusicGenreSelector: boolean;
|
||||||
|
showAudioModeSelector: boolean;
|
||||||
|
showMusicDurationSelector: boolean;
|
||||||
|
showTextEffectSelector: boolean;
|
||||||
|
showTransitionSelector: boolean;
|
||||||
|
showAspectRatioSelector: boolean;
|
||||||
|
showVisualStyleSelector: boolean;
|
||||||
|
showTtsConfig: boolean;
|
||||||
|
|
||||||
|
// 고급 기능
|
||||||
|
showFestivalIntegration: boolean;
|
||||||
|
showAiImageGeneration: boolean;
|
||||||
|
showCustomCss: boolean;
|
||||||
|
showSeoEditor: boolean;
|
||||||
|
showThumbnailSelector: boolean;
|
||||||
|
|
||||||
|
// 메뉴 표시
|
||||||
|
showAssetLibrary: boolean;
|
||||||
|
showPensionManagement: boolean;
|
||||||
|
showAdvancedSettings: boolean;
|
||||||
|
showFestivalMenu: boolean;
|
||||||
|
|
||||||
|
// 자동화
|
||||||
|
autoUpload: boolean;
|
||||||
|
showUploadOptions: boolean;
|
||||||
|
showScheduleSettings: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 생성 설정
|
||||||
|
export interface AutoGenerationSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
dayOfWeek: number; // 0=일, 1=월, ..., 6=토
|
||||||
|
timeOfDay: string; // "09:00"
|
||||||
|
pensionId?: number;
|
||||||
|
lastGeneratedAt?: string;
|
||||||
|
nextScheduledAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type Language = 'KO' | 'EN' | 'JP' | 'CN' | 'TH' | 'VN';
|
export type Language = 'KO' | 'EN' | 'JP' | 'CN' | 'TH' | 'VN';
|
||||||
|
|
||||||
// 펜션 카테고리 타입
|
// 펜션 카테고리 타입
|
||||||
|
|
@ -65,6 +110,14 @@ export interface BusinessInfo {
|
||||||
language: Language;
|
language: Language;
|
||||||
useAiImages: boolean; // AI 이미지 생성 허용 여부
|
useAiImages: boolean; // AI 이미지 생성 허용 여부
|
||||||
pensionCategories?: PensionCategory[]; // 펜션 카테고리 (복수 선택)
|
pensionCategories?: PensionCategory[]; // 펜션 카테고리 (복수 선택)
|
||||||
|
nearbyFestivals?: { // 근처 축제 정보 (콘텐츠 생성에 활용)
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
eventstartdate?: string;
|
||||||
|
eventenddate?: string;
|
||||||
|
addr1?: string;
|
||||||
|
distance?: number;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeneratedAssets {
|
export interface GeneratedAssets {
|
||||||
|
|
@ -268,3 +321,81 @@ export interface StorageStats {
|
||||||
videoSize: number; // bytes
|
videoSize: number; // bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 축제 정보 타입
|
||||||
|
export interface NearbyFestival {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
addr1?: string;
|
||||||
|
addr2?: string;
|
||||||
|
eventstartdate?: string;
|
||||||
|
eventenddate?: string;
|
||||||
|
tel?: string;
|
||||||
|
firstimage?: string;
|
||||||
|
firstimage2?: string;
|
||||||
|
mapx?: number;
|
||||||
|
mapy?: number;
|
||||||
|
distance?: number; // km 단위 거리
|
||||||
|
overview?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Business DNA - 펜션/숙소의 브랜드 DNA 분석 결과
|
||||||
|
export interface BusinessDNA {
|
||||||
|
// 기본 정보
|
||||||
|
name: string;
|
||||||
|
tagline?: string; // 한 줄 슬로건
|
||||||
|
|
||||||
|
// 톤 & 매너
|
||||||
|
toneAndManner: {
|
||||||
|
primary: string; // 예: "Warm & Cozy", "Luxurious & Elegant", "Modern & Minimal"
|
||||||
|
secondary?: string;
|
||||||
|
description: string; // 상세 설명
|
||||||
|
};
|
||||||
|
|
||||||
|
// 타겟 고객
|
||||||
|
targetCustomers: {
|
||||||
|
primary: string; // 예: "Young Couples", "Families with Kids", "Solo Travelers"
|
||||||
|
secondary?: string[];
|
||||||
|
ageRange?: string; // 예: "25-35"
|
||||||
|
characteristics?: string[]; // 특성들
|
||||||
|
};
|
||||||
|
|
||||||
|
// 브랜드 컬러
|
||||||
|
brandColors: {
|
||||||
|
primary: string; // HEX 코드, 예: "#4A90A4"
|
||||||
|
secondary?: string;
|
||||||
|
accent?: string;
|
||||||
|
palette?: string[]; // 전체 팔레트
|
||||||
|
mood: string; // 컬러가 주는 분위기
|
||||||
|
};
|
||||||
|
|
||||||
|
// 키워드 & 해시태그
|
||||||
|
keywords: {
|
||||||
|
primary: string[]; // 핵심 키워드 3-5개
|
||||||
|
secondary?: string[]; // 부가 키워드
|
||||||
|
hashtags?: string[]; // 추천 해시태그
|
||||||
|
};
|
||||||
|
|
||||||
|
// 시각적 스타일 (NEW)
|
||||||
|
visualStyle: {
|
||||||
|
interior: string; // 예: "Scandinavian Minimalist", "Korean Traditional Hanok", "Industrial Loft"
|
||||||
|
exterior: string; // 예: "Mountain Lodge", "Seaside Villa", "Forest Cabin"
|
||||||
|
atmosphere: string; // 예: "Serene & Peaceful", "Vibrant & Energetic", "Romantic & Intimate"
|
||||||
|
photoStyle: string; // 사진 스타일: "Warm Natural Light", "Moody & Dramatic", "Bright & Airy"
|
||||||
|
suggestedFilters?: string[]; // 추천 필터/톤: ["Warm", "High Contrast", "Muted Colors"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 차별화 포인트
|
||||||
|
uniqueSellingPoints: string[];
|
||||||
|
|
||||||
|
// 분위기/무드
|
||||||
|
mood: {
|
||||||
|
primary: string;
|
||||||
|
emotions: string[]; // 고객이 느낄 감정들
|
||||||
|
};
|
||||||
|
|
||||||
|
// 메타 정보
|
||||||
|
analyzedAt: string;
|
||||||
|
confidence: number; // 0-1 분석 신뢰도
|
||||||
|
sources?: string[]; // 분석에 사용된 소스들
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue