From 07a3a1093b107948301e096c9138a689827e74bd Mon Sep 17 00:00:00 2001 From: jaehwang Date: Thu, 11 Dec 2025 16:24:16 +0900 Subject: [PATCH] version19 --- .env.example | 193 ++ .env.production.example | 173 ++ .gitignore | 13 + README.md | 1635 ++++++++-- components/ResultPlayer.tsx | 417 ++- deploy.sh | 295 +- docs/FESTIVAL_PENSION_INTEGRATION.md | 2167 +++++++++++++ docs/images/architecture-distributed.svg | 168 + docs/images/architecture-kubernetes.svg | 183 ++ docs/images/architecture-single-server.svg | 112 + nginx.castad.conf | 140 + package-lock.json | 219 +- package.json | 3 +- plan.md | 337 ++ public/images/architecture-diagram.svg | 274 ++ public/images/render-queue-architecture.svg | 196 ++ server/billingService.js | 309 ++ server/client_secret.json | 2 +- server/client_secret.json_Zone.Identifier | Bin 25 -> 0 bytes server/db.js | 391 +++ server/geminiBackendService.js | 468 ++- server/index.js | 2732 ++++++++++++++++- server/instagram/instagram_service.py | 13 +- .../001_festival_pension_tables.sql | 263 ++ server/package-lock.json | 1343 ++++---- server/package.json | 5 +- server/scripts/syncData.js | 440 +++ server/scripts/weeklySync.sh | 43 + server/services/autoUploadService.js | 311 ++ server/services/festivalService.js | 445 +++ server/services/geocodingService.js | 355 +++ server/services/schedulerService.js | 249 ++ server/services/tourApiClient.js | 439 +++ services/geminiService.ts | 57 +- services/googlePlacesService.ts | 2 +- services/instagramService.ts | 160 + services/naverService.ts | 4 +- setup-nginx-castad1.sh | 196 ++ src/components/DNACard.tsx | 296 ++ src/components/KoreaMap.tsx | 194 ++ src/components/PensionOnboardingDialog.tsx | 415 +++ src/components/layout/Sidebar.tsx | 74 +- src/components/settings/LevelSelector.tsx | 158 + src/contexts/UserLevelContext.tsx | 219 ++ src/locales.ts | 12 +- src/pages/AdminDashboard.tsx | 918 +++++- src/pages/CastADApp.tsx | 55 +- src/pages/LandingPage.tsx | 196 +- src/views/AccountView.tsx | 27 + src/views/DashboardView.tsx | 209 +- src/views/FestivalsView.tsx | 675 ++++ src/views/LibraryView.tsx | 171 +- src/views/NewProjectView.tsx | 1653 +++++++++- src/views/PensionsView.tsx | 494 ++- src/views/SettingsView.tsx | 27 +- start.sh | 3 + startserver.sh | 1365 ++------ test_api.sh | 0 types.ts | 131 + 59 files changed, 19645 insertions(+), 2399 deletions(-) create mode 100644 .env.example create mode 100644 .env.production.example mode change 100644 => 100755 deploy.sh create mode 100644 docs/FESTIVAL_PENSION_INTEGRATION.md create mode 100644 docs/images/architecture-distributed.svg create mode 100644 docs/images/architecture-kubernetes.svg create mode 100644 docs/images/architecture-single-server.svg create mode 100644 nginx.castad.conf create mode 100644 plan.md create mode 100644 public/images/architecture-diagram.svg create mode 100644 public/images/render-queue-architecture.svg create mode 100644 server/billingService.js delete mode 100644 server/client_secret.json_Zone.Identifier create mode 100644 server/migrations/001_festival_pension_tables.sql create mode 100644 server/scripts/syncData.js create mode 100755 server/scripts/weeklySync.sh create mode 100644 server/services/autoUploadService.js create mode 100644 server/services/festivalService.js create mode 100644 server/services/geocodingService.js create mode 100644 server/services/schedulerService.js create mode 100644 server/services/tourApiClient.js create mode 100644 services/instagramService.ts create mode 100755 setup-nginx-castad1.sh create mode 100644 src/components/DNACard.tsx create mode 100644 src/components/KoreaMap.tsx create mode 100644 src/components/PensionOnboardingDialog.tsx create mode 100644 src/components/settings/LevelSelector.tsx create mode 100644 src/contexts/UserLevelContext.tsx create mode 100644 src/views/FestivalsView.tsx mode change 100644 => 100755 start.sh mode change 100644 => 100755 startserver.sh mode change 100644 => 100755 test_api.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..45ea3bf --- /dev/null +++ b/.env.example @@ -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 + +# ============================================ +# 🔐 소셜 로그인 (선택) +# ============================================ + +# 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_* (비용 분석) diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..f61cb4f --- /dev/null +++ b/.env.production.example @@ -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 + +# ============================================ +# 🔐 소셜 로그인 (선택) +# ============================================ + +# 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 (사용자 토큰, 자동생성) diff --git a/.gitignore b/.gitignore index fb0e0a6..6164900 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,19 @@ server/database.sqlite .env .env.* !.env.example +!.env.production.example + +# Sensitive files (API keys, credentials) +server/google-billing-key.json +server/client_secret.json +server/youtube-tokens.json +**/tokens.json + +# Analysis files +*_analysis_*.txt + +# Windows Zone.Identifier files +*:Zone.Identifier # Python __pycache__/ diff --git a/README.md b/README.md index 2dbbf84..9602ae3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Google Gemini AI와 Suno AI를 활용하여 광고 카피, 음악, 영상을 자동으로 생성하고, OAuth 2.0 기반 YouTube, TikTok 채널 연동을 통해 직접 업로드까지 지원합니다. ![Project Status](https://img.shields.io/badge/Status-Production-success) -![Version](https://img.shields.io/badge/Version-3.1.0-blue) +![Version](https://img.shields.io/badge/Version-3.7.0-blue) ![License](https://img.shields.io/badge/License-MIT-green) ![Tech Stack](https://img.shields.io/badge/Stack-React%2019%20%7C%20Node.js%20%7C%20SQLite-yellow) ![Platforms](https://img.shields.io/badge/Platforms-YouTube%20%7C%20TikTok%20%7C%20Instagram-red) @@ -41,9 +41,10 @@ Google Gemini AI와 Suno AI를 활용하여 광고 카피, 음악, 영상을 자 - [B5. 관리자 대시보드](#b5-관리자-대시보드) - [B6. 사용자 관리](#b6-사용자-관리) - [B7. 플랜 및 크레딧 관리](#b7-플랜-및-크레딧-관리) -- [B8. 시스템 모니터링](#b8-시스템-모니터링) -- [B9. 백업 및 복구](#b9-백업-및-복구) -- [B10. 트러블슈팅](#b10-트러블슈팅) +- [B8. 사진 관리 시스템](#b8-사진-관리-시스템) +- [B9. 시스템 모니터링](#b9-시스템-모니터링) +- [B10. 백업 및 복구](#b10-백업-및-복구) +- [B11. 트러블슈팅](#b11-트러블슈팅) ### Part C. 사용자 매뉴얼 (User Manual) - [C1. 시작하기](#c1-시작하기) @@ -132,6 +133,35 @@ Google Gemini AI와 Suno AI를 활용하여 광고 카피, 음악, 영상을 자 | **비디오 배경 생성** | Imagen Video로 동영상 배경 자동 생성 | | **광고 포스터 생성** | Imagen 3으로 AI 포스터 이미지 생성 | | **사업 정보 검색** | Google Maps Tool로 펜션 정보 자동 수집 | +| **🧬 비즈니스 DNA 분석** | Google Search Grounding으로 브랜드 DNA 자동 추출 | + +### 🧬 비즈니스 DNA 분석 (NEW!) + +펜션의 브랜드 아이덴티티를 AI가 자동으로 분석합니다. + +| 분석 항목 | 설명 | +|----------|------| +| **톤앤매너** | 브랜드 커뮤니케이션 스타일 (프리미엄, 감성적, 모던 등) | +| **타겟 고객** | 주요/부차 타겟층, 연령대, 라이프스타일 특성 | +| **브랜드 컬러** | 이미지 기반 컬러 팔레트 자동 추출 (5-6색) | +| **키워드** | SEO 최적화 핵심 키워드 + SNS 해시태그 | +| **시각적 스타일** | 인테리어/외관/분위기 분석 + 추천 사진 스타일 | +| **차별화 포인트** | 경쟁력 있는 고유 가치 제안 (USP) 3-5개 | + +**기술 스택:** +- Google Search Grounding: 실시간 웹 검색으로 최신 정보 수집 +- Gemini 2.5 Flash: 멀티모달 분석 (텍스트 + 이미지) +- DNACard 컴포넌트: 분석 결과 시각화 + +### 📸 스마트 이미지 관리 (NEW!) + +| 기능 | 설명 | +|------|------| +| **확장된 크롤링** | 네이버 플레이스에서 최대 30장 자동 수집 | +| **다중 소스** | 메뉴, 업체, 객실, 방문자 이미지 통합 수집 | +| **영구 저장** | 크롤링된 이미지를 펜션 프로필에 자동 저장 | +| **자동 로드** | 펜션 선택 시 저장된 이미지 즉시 불러오기 | +| **선택적 사용** | 전체/개별/랜덤 이미지 선택 모드 | ### 🎙️ AI 음성 합성 (TTS) @@ -241,6 +271,193 @@ Google Gemini AI와 Suno AI를 활용하여 광고 카피, 음악, 영상을 자 - 자동 썸네일 생성 - 메타데이터 관리 (크기, 해상도, 길이) +### 🎪 축제 연동 시스템 (v3.2.0 신규) + +펜션 근처 축제 정보를 자동으로 가져와 마케팅 콘텐츠에 반영합니다. + +| 기능 | 설명 | +|------|------| +| **데이터 소스** | 한국관광공사 Tour API | +| **자동 동기화** | 관리자가 수동 또는 스케줄 동기화 | +| **지역 기반 추천** | 펜션 주소 기반 가까운 축제 자동 표시 | +| **행정구역 그룹핑** | 시/도 단위로 축제 목록 그룹화 | +| **콘텐츠 연동** | 선택한 축제가 가사/광고 카피에 자동 반영 | + +**축제 데이터 구조:** +```json +{ + "id": 12345, + "title": "강릉 커피 축제", + "sido": "강원", + "sigungu": "강릉시", + "addr1": "강원특별자치도 강릉시 ...", + "event_start_date": "20241201", + "event_end_date": "20241215", + "first_image": "https://..." +} +``` + +**API 엔드포인트:** +| 엔드포인트 | 설명 | +|-----------|------| +| `GET /api/festivals` | 축제 목록 조회 (sido, sigungu 필터) | +| `GET /api/festivals/grouped` | 행정구역별 그룹 조회 | +| `GET /api/festivals/:id` | 축제 상세 정보 | +| `POST /api/admin/sync/festivals` | 축제 데이터 동기화 (관리자) | + +### 💰 Google Cloud Billing 연동 (v3.2.0 신규) + +BigQuery를 통해 실제 Google Cloud 비용을 실시간으로 추적합니다. + +**설정 단계:** +1. Google Cloud Console에서 Billing Export to BigQuery 활성화 +2. 서비스 계정 생성 및 BigQuery 권한 부여 +3. `google-billing-key.json` 파일을 `server/` 폴더에 배치 +4. 환경 변수 설정 + +**환경 변수:** +```env +GOOGLE_BILLING_KEY_PATH=./server/google-billing-key.json +GOOGLE_CLOUD_PROJECT_ID=your-project-id +GOOGLE_BILLING_DATASET_ID=billing_export +``` + +**대시보드 기능:** +| 기능 | 설명 | +|------|------| +| **서비스별 비용** | Vertex AI, Cloud Run 등 서비스별 집계 | +| **일별 트렌드** | 최근 30일 비용 추이 그래프 | +| **SKU 상세** | Gemini API 모델별 사용량 및 비용 | +| **월별 요약** | 월간 비용 요약 및 서비스 분포 | +| **환율 적용** | USD → KRW 자동 환산 (1,350원 기준) | + +**API 엔드포인트:** +| 엔드포인트 | 설명 | +|-----------|------| +| `GET /api/admin/billing/dashboard` | 빌링 대시보드 데이터 | +| `GET /api/admin/billing/status` | 빌링 데이터 사용 가능 여부 | + +### 📊 API 사용량 추적 (v3.2.0 신규) + +모든 외부 API 호출을 추적하여 비용 분석 및 과금 대비 데이터를 제공합니다. + +**추적 대상:** +| 서비스 | 추적 항목 | +|--------|----------| +| **Gemini** | 모델명, 입/출력 토큰, 이미지 수, 지연시간 | +| **Suno** | 오디오 길이, 장르, 생성 시간 | +| **YouTube** | 업로드 영상 수, 용량 | +| **TikTok** | 업로드 수, 상태 | + +**데이터베이스 스키마:** +```sql +CREATE TABLE api_usage_logs ( + id INTEGER PRIMARY KEY, + service TEXT NOT NULL, -- 'gemini', 'suno', 'youtube' + model TEXT, -- 'gemini-2.0-flash', etc. + endpoint TEXT, -- API 엔드포인트 + user_id INTEGER, -- 사용자 ID (과금용) + tokens_input INTEGER, -- 입력 토큰 + tokens_output INTEGER, -- 출력 토큰 + image_count INTEGER, -- 이미지 수 + audio_seconds REAL, -- 오디오 길이 + status TEXT, -- 'success', 'error' + error_message TEXT, + latency_ms INTEGER, -- 응답 시간 + cost_estimate REAL, -- 예상 비용 (USD) + createdAt DATETIME +); +``` + +**관리자 대시보드:** +- 총 API 호출 수 / 예상 비용 +- 서비스별/모델별 사용량 분포 +- 사용자별 사용량 랭킹 (과금 대비) +- 최근 오류 로그 +- 월별 리포트 자동 생성 + +### 🎯 AI 모델 우선순위 시스템 (v3.2.0 신규) + +API 오류 시 자동으로 대체 모델로 전환하는 지능적 Fallback 시스템입니다. + +**이미지 생성 모델 우선순위:** +| 순위 | 모델 | 예상 비용 | 최대 재시도 | +|------|------|----------|------------| +| 1 | gemini-2.0-flash-preview-image-generation | $0.02/이미지 | 2회 | +| 2 | gemini-2.5-flash-image | $0.015/이미지 | 2회 | +| 3 | imagen-3.0-generate-002 | $0.03/이미지 | 1회 | + +**Fallback 트리거:** +- HTTP 429 (Rate Limit Exceeded) +- HTTP 500 (Internal Server Error) +- 타임아웃 (30초 초과) + +**로깅:** +모든 모델 전환 시도는 `api_usage_logs` 테이블에 기록되어 추후 분석에 활용됩니다. + +### ✨ AI 매직 라이트 (v3.2.0 신규) + +펜션 정보를 기반으로 매력적인 마케팅 컨셉 설명을 자동 생성합니다. + +**입력 데이터:** +- 펜션 이름 +- 주소/지역 +- 펜션 카테고리 (풀빌라, 오션뷰 등) +- 선택된 축제 정보 +- 기존 설명 (참고용) + +**출력:** +2-3문장의 감성적이고 매력적인 마케팅 컨셉 설명 + +**사용 방법:** +1. 펜션 기본 정보 입력 +2. 카테고리 선택 +3. 근처 축제 선택 (선택사항) +4. "AI 매직 라이트" 버튼 클릭 +5. 자동 생성된 컨셉 확인/수정 + +**API:** +``` +POST /api/ai/auto-description +Body: { name, address, pensionCategories, selectedFestivals, existingDescription } +Response: { description, success } +``` + +### 🔐 개별 YouTube 연동 (v3.2.0 개선) + +각 사용자가 자신의 YouTube 채널을 연결하여 직접 업로드할 수 있습니다. + +**OAuth 2.0 플로우:** +``` +1. 사용자: "YouTube 연결" 클릭 +2. → Google OAuth 페이지로 리다이렉트 +3. 사용자: Google 계정 로그인 + 권한 승인 +4. → 콜백 URL로 인증 코드 반환 +5. 서버: 인증 코드 → 액세스 토큰 교환 +6. 서버: 토큰을 사용자 DB에 저장 +7. 이후 업로드 시 저장된 토큰 사용 +``` + +**설정 요구사항 (개발자):** +1. Google Cloud Console에서 OAuth 2.0 클라이언트 생성 +2. YouTube Data API v3 활성화 +3. 승인된 리디렉션 URI 등록: + - `http://localhost:3001/api/youtube/oauth/callback` (개발) + - `https://castad1.ktenterprise.net/api/youtube/oauth/callback` (프로덕션) + +**사용자 경험:** +- 설정 페이지에서 "YouTube 연결" 버튼 클릭 +- Google 계정으로 로그인 및 권한 승인 +- 연결 완료 후 자신의 채널에 직접 업로드 가능 + +**API 엔드포인트:** +| 엔드포인트 | 설명 | +|-----------|------| +| `GET /api/youtube/oauth/url` | OAuth 인증 URL 생성 | +| `GET /api/youtube/oauth/callback` | OAuth 콜백 처리 | +| `GET /api/youtube/connection` | 연결 상태 확인 | +| `POST /api/youtube/my-upload` | 사용자 채널에 업로드 | + --- # Part A. 개발자 매뉴얼 (Developer Manual) @@ -251,57 +468,102 @@ Google Gemini AI와 Suno AI를 활용하여 광고 카피, 음악, 영상을 자 ## A1. 시스템 아키텍처 -### 고수준 아키텍처 다이어그램 +### 시스템 아키텍처 다이어그램 -```mermaid -flowchart TB - subgraph CLIENT["🖥️ CLIENT LAYER"] - LP[Landing Page] - CA[CastAD App] - AD[Admin Dashboard] - LP & CA & AD --> RR[React Router v7] - RR --> CTX[AuthContext / LanguageContext / ThemeContext] - end +> **SVG 다이어그램 보기:** [public/images/architecture-diagram.svg](public/images/architecture-diagram.svg) - subgraph SERVER["⚙️ SERVER LAYER"] - subgraph EXPRESS["Express.js Application"] - AR[Auth Routes] - PR[Pension Routes] - YR[YouTube Routes] - RE[Render Engine] - AR & PR & YR & RE --> MW[Middleware: JWT / Rate Limit / CORS] - end - end +

+ CaStAD System Architecture +

- subgraph DATA["💾 DATA LAYER"] - DB[(SQLite Database)] - PP[Puppeteer Headless] - FF[FFmpeg Encoder] - end +### 계층별 구조 - subgraph EXTERNAL["🌐 EXTERNAL APIs"] - GM[Google Gemini API] - SN[Suno AI Proxy] - YT[YouTube Data API] - end +#### 1. 클라이언트 계층 (Frontend) - CLIENT -->|REST API JSON| SERVER - SERVER --> DATA - SERVER --> EXTERNAL +| 컴포넌트 | 설명 | +|---------|------| +| **Landing Page** | 마케팅 홈페이지, 기능 소개 | +| **CastAD App** | 메인 SPA 애플리케이션 | +| **Admin Dashboard** | 관리자 전용 대시보드 | +| **React Router v7** | SPA 라우팅 관리 | +| **Context API** | Auth, Language, Theme, UserLevel 전역 상태 | + +#### 2. 서버 계층 (Backend) + +| 모듈 | 역할 | +|------|------| +| **Auth Routes** | 인증/인가 (JWT, OAuth 2.0) | +| **Pension Routes** | 펜션 CRUD, 이미지 관리 | +| **YouTube Routes** | OAuth, 업로드, 플레이리스트 | +| **TikTok Routes** | OAuth, Direct Post, Inbox 업로드 | +| **Instagram Routes** | 계정 연결, Reels 업로드 | +| **Gemini Routes** | AI 콘텐츠 생성 프록시 | +| **Crawling Routes** | 네이버/구글/인스타그램 크롤링 | +| **Render Engine** | Puppeteer + FFmpeg 영상 생성 | + +#### 3. 데이터 계층 + +| 컴포넌트 | 용도 | +|---------|------| +| **SQLite** | 메타데이터, 사용자, 설정 저장 | +| **Puppeteer** | 헤드리스 브라우저 렌더링 | +| **FFmpeg** | 오디오/비디오 병합 | +| **FileSystem** | 생성된 MP4, 이미지 저장 | + +#### 4. 외부 API + +| API | 용도 | +|-----|------| +| **Google Gemini** | 광고 카피, TTS, 이미지 생성 | +| **Suno AI** | AI 음악 생성 | +| **YouTube Data API** | 영상 업로드, 플레이리스트 | +| **TikTok API** | TikTok 영상 게시 | +| **Instagram API** | Reels 업로드 (Python 마이크로서비스) | +| **Tour API** | 한국관광공사 축제 데이터 | +| **Kakao Maps API** | 지오코딩 | + +### 영상 생성 파이프라인 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 1. INPUT │ +│ 펜션 정보 입력 → 네이버/구글/인스타 크롤링 → 이미지 15장 선택 │ +└─────────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 2. AI GENERATION │ +│ 광고 카피 (Gemini) │ 음악 (Suno AI) │ 포스터 (Imagen 3) │ TTS (Gemini) │ +└─────────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 3. RENDERING │ +│ HTML/CSS 애니메이션 → Puppeteer 캡처 → FFmpeg 오디오 병합 │ +└─────────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 4. POST-PROCESSING │ +│ 해상도 최적화 │ 메타데이터 추출 │ 썸네일 생성 │ +└─────────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 5. UPLOAD │ +│ YouTube (OAuth) │ TikTok (OAuth) │ Instagram (Reels) │ +└─────────────────────────────────────────────────────────────────────────────┘ ``` -### 데이터 흐름 +### 데이터 흐름 다이어그램 ```mermaid flowchart TD - A[📝 사용자 입력] --> B[🗺️ 네이버/구글 지도 크롤링] - B -->|Puppeteer| C[🤖 AI 콘텐츠 생성] + A[📝 사용자 입력] --> B[🗺️ 네이버/구글/인스타 크롤링] + B --> C[🤖 AI 콘텐츠 생성] C --> C1[광고 카피 - Gemini 2.5 Flash] C --> C2[음악 생성 - Suno AI v5] C --> C3[포스터 생성 - Imagen 3] + C --> C4[음성 합성 - Gemini TTS] - C1 & C2 & C3 --> D[🎬 영상 렌더링] + C1 & C2 & C3 & C4 --> D[🎬 영상 렌더링] D --> D1[HTML/CSS 애니메이션] D --> D2[Puppeteer 화면 캡처] @@ -311,8 +573,10 @@ flowchart TD E --> E1[(SQLite - 메타데이터)] E --> E2[📁 FileSystem - MP4] - E --> F[📺 YouTube 업로드] - F -->|OAuth 2.0| G[YouTube Data API v3] + E --> F[📺 멀티 플랫폼 업로드] + F -->|OAuth 2.0| F1[YouTube] + F -->|OAuth 2.0| F2[TikTok] + F -->|API| F3[Instagram Reels] ``` --- @@ -359,138 +623,284 @@ flowchart TD ## A3. 프로젝트 구조 +### 디렉토리 구조 + ``` -19-claude-saas-castad/ +19-claude-festival-castad/ ├── 📁 components/ # 공통 React 컴포넌트 │ ├── InputForm.tsx # 영상 생성 입력 폼 │ ├── LoadingOverlay.tsx # 로딩 화면 │ ├── Navbar.tsx # 상단 네비게이션 │ ├── ResultPlayer.tsx # 결과 영상 플레이어 -│ └── ui/ # shadcn/ui 컴포넌트 +│ ├── DNACard.tsx # 펜션 DNA 분석 카드 +│ ├── KoreaMap.tsx # 한국 지도 시각화 +│ ├── OnboardingDialog.tsx # 사용자 온보딩 +│ ├── PensionOnboardingDialog.tsx # 펜션 등록 다이얼로그 +│ ├── YouTubeSEOPreview.tsx # YouTube SEO 프리뷰 +│ └── ui/ # shadcn/ui 컴포넌트 (30+) │ ├── 📁 src/ │ ├── 📁 components/ # 앱 전용 컴포넌트 │ │ ├── layout/ # 레이아웃 컴포넌트 -│ │ │ ├── Sidebar.tsx # 사이드바 -│ │ │ └── TopHeader.tsx# 상단 헤더 -│ │ └── YouTubeSEOPreview.tsx +│ │ │ ├── Sidebar.tsx # 사이드 네비게이션 +│ │ │ ├── TopHeader.tsx# 상단 헤더 +│ │ │ └── SidebarItem.tsx # 사이드바 메뉴 항목 +│ │ └── wizard/ # 마법사 컴포넌트 +│ │ └── StepIndicator.tsx # 단계 표시기 │ │ -│ ├── 📁 contexts/ # React Context -│ │ ├── AuthContext.tsx # 인증 상태 관리 -│ │ ├── LanguageContext.tsx # 다국어 관리 -│ │ └── ThemeContext.tsx # 테마 관리 +│ ├── 📁 contexts/ # React Context (전역 상태) +│ │ ├── AuthContext.tsx # JWT 인증 상태 +│ │ ├── LanguageContext.tsx # 다국어 (6개국) +│ │ ├── ThemeContext.tsx # 다크/라이트 모드 + 컬러 팔레트 +│ │ └── UserLevelContext.tsx # 사용자 레벨 (Beginner/Pro) │ │ -│ ├── 📁 pages/ # 페이지 컴포넌트 -│ │ ├── AdminDashboard.tsx # 관리자 대시보드 -│ │ ├── CastADApp.tsx # 메인 앱 레이아웃 -│ │ ├── CreditsPage.tsx # 크레딧 페이지 -│ │ ├── LandingPage.tsx # 랜딩 페이지 -│ │ ├── LoginPage.tsx # 로그인 -│ │ ├── RegisterPage.tsx # 회원가입 +│ ├── 📁 pages/ # 페이지 컴포넌트 (12개) +│ │ ├── GeneratorPage.tsx # 레거시 영상 생성 페이지 +│ │ ├── Dashboard.tsx # 레거시 대시보드 +│ │ ├── CastADApp.tsx # 메인 SPA 레이아웃 +│ │ ├── AdminDashboard.tsx # 관리자 대시보드 (6000+ lines) +│ │ ├── LandingPage.tsx # 마케팅 랜딩 +│ │ ├── BrandPage.tsx # 브랜드 소개 +│ │ ├── LoginPage.tsx # 로그인 +│ │ ├── RegisterPage.tsx # 회원가입 +│ │ ├── CreditsPage.tsx # 크레딧 관리 │ │ ├── OAuthCallbackPage.tsx # OAuth 콜백 -│ │ └── ... +│ │ ├── ForgotPasswordPage.tsx # 비밀번호 찾기 +│ │ └── VerifyEmailPage.tsx # 이메일 인증 │ │ -│ ├── 📁 views/ # 앱 내 뷰 컴포넌트 -│ │ ├── DashboardView.tsx # 대시보드 뷰 -│ │ ├── NewProjectView.tsx # 새 프로젝트 -│ │ ├── LibraryView.tsx # 라이브러리 -│ │ ├── PensionsView.tsx # 펜션 관리 -│ │ ├── AssetsView.tsx # 에셋 관리 -│ │ ├── SettingsView.tsx # 설정 -│ │ └── AccountView.tsx # 계정 관리 +│ ├── 📁 views/ # 앱 내 뷰 컴포넌트 (8개) +│ │ ├── DashboardView.tsx # 대시보드 (19.8KB) +│ │ ├── NewProjectView.tsx # 새 영상 생성 마법사 (112KB, 가장 복잡) +│ │ ├── LibraryView.tsx # 영상 라이브러리 (14.5KB) +│ │ ├── PensionsView.tsx # 펜션 프로필 관리 (51.5KB) +│ │ ├── FestivalsView.tsx # 축제 정보 (26.4KB) +│ │ ├── AssetsView.tsx # 에셋 관리 (24.8KB) +│ │ ├── SettingsView.tsx # 설정 (57.9KB) +│ │ └── AccountView.tsx # 계정 정보 (16.2KB) │ │ │ ├── 📁 styles/ # 스타일시트 │ │ └── globals.css # 전역 CSS │ │ -│ └── locales.ts # 다국어 번역 데이터 +│ └── locales.ts # 다국어 번역 (KO/EN/JA/ZH/TH/VI) │ -├── 📁 services/ # 프론트엔드 API 서비스 -│ ├── geminiService.ts # Gemini API 호출 -│ ├── sunoService.ts # Suno AI 호출 -│ └── ffmpegService.ts # FFmpeg 유틸리티 +├── 📁 services/ # 프론트엔드 API 서비스 (7개) +│ ├── geminiService.ts # Gemini AI API (텍스트/이미지/TTS) +│ ├── sunoService.ts # Suno AI 음악 생성 +│ ├── naverService.ts # 네이버 플레이스 크롤링 +│ ├── googlePlacesService.ts # Google Places 크롤링 +│ ├── instagramService.ts # Instagram 프로필 크롤링 (v3.6.0) +│ ├── ffmpegService.ts # FFmpeg WASM (클라이언트) +│ └── audioUtils.ts # 오디오 유틸리티 │ ├── 📁 server/ # 백엔드 서버 -│ ├── index.js # Express 메인 서버 (4000+ lines) -│ ├── db.js # SQLite 스키마 및 초기화 -│ ├── geminiBackendService.js # 서버 Gemini 호출 -│ ├── youtubeService.js # YouTube API 서비스 -│ ├── tiktokService.js # TikTok API 서비스 (v3.0 신규) -│ ├── statisticsService.js # 고급 통계 서비스 (v3.0 신규) -│ ├── instagram_service.py # Instagram 업로드 (Python) +│ ├── index.js # Express 메인 (7200+ lines, 150+ API) +│ ├── db.js # SQLite 스키마 (850+ lines, 30+ 테이블) +│ ├── geminiBackendService.js # Gemini 백엔드 프록시 +│ ├── youtubeService.js # YouTube Data API v3 +│ ├── tiktokService.js # TikTok API 서비스 +│ ├── statisticsService.js # 고급 통계 분석 +│ ├── 📁 instagram-service/ # Instagram 마이크로서비스 (Python) +│ │ ├── instagram_service.py +│ │ └── requirements.txt │ ├── 📁 downloads/ # 생성된 영상 저장 -│ └── 📁 temp/ # 임시 렌더링 파일 +│ ├── 📁 temp/ # 임시 렌더링 파일 +│ └── 📁 user_assets/ # 사용자 업로드 파일 +│ +├── 📁 public/ # 정적 파일 +│ └── images/ +│ ├── castad-logo.svg +│ └── architecture-diagram.svg │ ├── App.tsx # React 앱 루트 ├── types.ts # TypeScript 타입 정의 -├── vite.config.ts # Vite 설정 +├── vite.config.ts # Vite 설정 (프록시 포함) ├── tailwind.config.js # Tailwind 설정 ├── package.json # 프론트엔드 의존성 ├── start.sh # 통합 실행 스크립트 ├── test_api.sh # API 테스트 스크립트 +├── CLAUDE.md # Claude Code 가이드 └── .env # 환경 변수 (Git 제외) ``` +### Context API 상세 + +| Context | Hook | 제공 기능 | +|---------|------|----------| +| **AuthContext** | `useAuth()` | user, token, login, logout, isAdmin | +| **LanguageContext** | `useLanguage()` | language, setLanguage, t(key) | +| **ThemeContext** | `useTheme()` | theme, palette, setTheme, toggleTheme | +| **UserLevelContext** | `useUserLevel()` | level, setLevel, features, isLoading | + +### 프론트엔드 서비스 레이어 + +| 서비스 | 주요 함수 | 용도 | +|--------|----------|------| +| **geminiService** | generateCreativeContent(), synthesizeSpeech(), generateAdPoster(), filterImages() | Gemini API 호출 | +| **sunoService** | generateMusic(), cleanLyricsForSuno() | AI 음악 생성 | +| **naverService** | crawlNaverPlace(), parseNaverPlaceUrl() | 네이버 지도 크롤링 | +| **googlePlacesService** | searchPlace(), getPlaceDetails(), getPlacePhoto() | Google Places 크롤링 | +| **instagramService** | crawlInstagramProfile(), parseInstagramUrl() | 인스타그램 크롤링 | +| **ffmpegService** | mergeVideoWithAudio(), initFFmpeg() | 클라이언트 비디오 처리 | +| **audioUtils** | decodeBase64Audio(), arrayBufferToWav() | 오디오 유틸리티 | + +### 라우팅 구조 + +``` +/ (root) +├── /login → LoginPage +├── /register → RegisterPage +├── /forgot-password → ForgotPasswordPage +├── /verify-email → VerifyEmailPage +├── /reset-password → ResetPasswordPage +├── /oauth/callback → OAuthCallbackPage +├── /landing → LandingPage +├── /brand → BrandPage +├── /credits → CreditsPage (인증 필요) +├── /admin → AdminDashboard (관리자 전용) +└── /app → CastADApp (인증 필요) + ├── ?view=dashboard → DashboardView + ├── ?view=new-project → NewProjectView + ├── ?view=library → LibraryView + ├── ?view=pensions → PensionsView + ├── ?view=festivals → FestivalsView + ├── ?view=assets → AssetsView + ├── ?view=settings → SettingsView + └── ?view=account → AccountView +``` + --- ## A4. 데이터베이스 스키마 +> SQLite 데이터베이스 파일: `server/database.sqlite` + +### 테이블 카테고리별 구성 (총 30+ 테이블) + +#### 1. 사용자 및 인증 (User & Auth) + +| 테이블 | 설명 | 주요 컬럼 | +|--------|------|----------| +| `users` | 사용자 계정, 플랜, 크레딧 | id, username, email, password, role, plan_type, credits, max_pensions, experience_level | +| `credit_requests` | 크레딧 충전 요청 | user_id, requested_credits, status, reason, processed_by | +| `credit_history` | 크레딧 변동 이력 | user_id, amount, type, description, balance_after | +| `activity_logs` | 시스템 활동 로그 | user_id, action_type, ip_address, metadata | + +#### 2. 펜션 관리 (Pension Management) + +| 테이블 | 설명 | 주요 컬럼 | +|--------|------|----------| +| `pension_profiles` | 펜션/숙소 프로필 (다중 지원) | user_id, brand_name, region, address, youtube_playlist_id, mapx, mapy | +| `pension_images` | 펜션 이미지 보관함 | pension_id, filename, source(`naver`/`google`/`instagram`/`upload`), is_priority | +| `daily_auto_generation` | 일일 자동 생성 설정 | pension_id, enabled, generation_time, image_mode, auto_upload_youtube/instagram/tiktok | +| `auto_generation_logs` | 자동 생성 로그 | pension_id, status, youtube_video_id, instagram_media_id, tiktok_video_id | + +#### 3. 영상 및 에셋 (Video & Assets) + +| 테이블 | 설명 | 주요 컬럼 | +|--------|------|----------| +| `history` | 생성된 영상 히스토리 | user_id, pension_id, business_name, details(JSON), final_video_path, poster_path | +| `user_assets` | 사용자 에셋 (이미지/오디오/비디오) | user_id, asset_type, source_type, file_path, file_size, duration | +| `generation_queue` | 자동 생성 작업 큐 | user_id, pension_id, status, scheduled_at, result_video_path | + +#### 4. YouTube 연동 + +| 테이블 | 설명 | 주요 컬럼 | +|--------|------|----------| +| `youtube_connections` | YouTube OAuth 토큰 | user_id, google_email, youtube_channel_id, access_token, refresh_token | +| `youtube_settings` | 업로드 기본 설정 | user_id, default_privacy, default_category_id, auto_upload | +| `youtube_playlists` | 플레이리스트 캐시 | user_id, pension_id, playlist_id, title, item_count | +| `upload_history` | YouTube 업로드 기록 | user_id, pension_id, youtube_video_id, youtube_url, status | +| `youtube_analytics` | YouTube 분석 데이터 캐시 | pension_id, playlist_id, date, views, likes, subscribers_gained | +| `pension_monthly_stats` | 월간 통계 요약 | pension_id, year_month, total_views, total_videos, growth_rate | + +#### 5. Instagram 연동 + +| 테이블 | 설명 | 주요 컬럼 | +|--------|------|----------| +| `instagram_connections` | Instagram 계정 연결 | user_id, instagram_username, encrypted_password, encrypted_session | +| `instagram_settings` | Instagram 업로드 설정 | user_id, auto_upload, upload_as_reel, default_hashtags | +| `instagram_upload_history` | Instagram 업로드 기록 | user_id, pension_id, instagram_media_id, permalink, upload_type | + +#### 6. TikTok 연동 + +| 테이블 | 설명 | 주요 컬럼 | +|--------|------|----------| +| `tiktok_connections` | TikTok OAuth 연결 | user_id, open_id, display_name, access_token, refresh_token | +| `tiktok_settings` | TikTok 업로드 설정 | user_id, default_privacy, disable_duet, auto_upload | +| `tiktok_upload_history` | TikTok 업로드 기록 | user_id, pension_id, video_id, privacy_level, status | + +#### 7. 외부 데이터 (TourAPI 연동) + +| 테이블 | 설명 | 주요 컬럼 | +|--------|------|----------| +| `festivals` | 전국 축제 정보 | content_id, title, addr1, event_start_date, event_end_date, mapx, mapy | +| `public_pensions` | 전국 펜션 마스터 데이터 | name, address, sido, sigungu, mapx, mapy, facilities, pet_allowed | +| `pension_festival_matches` | 펜션-축제 매칭 | pension_id, festival_id, distance_km, match_score | +| `area_codes` | 지역 코드 (17개 시도) | code, name, name_short, name_en | + +#### 8. 시스템 관리 + +| 테이블 | 설명 | 주요 컬럼 | +|--------|------|----------| +| `system_settings` | 시스템 설정 (쿠키 등) | setting_key, setting_value, is_encrypted, updated_by | +| `system_stats_daily` | 일별 시스템 통계 | date, total_users, new_users, total_videos_generated | +| `api_usage_logs` | API 사용량 로그 | service, model, user_id, tokens_input, tokens_output, cost_estimate | +| `api_usage_daily` | API 일별 집계 | date, service, model, total_calls, total_cost_estimate | +| `platform_stats` | 플랫폼 통합 통계 | user_id, platform, date, views, likes, engagement_rate | + ### ERD (Entity Relationship Diagram) ```mermaid erDiagram USERS ||--o{ PENSION_PROFILES : "owns" USERS ||--o| YOUTUBE_CONNECTIONS : "has" - USERS ||--o| YOUTUBE_SETTINGS : "has" + USERS ||--o| INSTAGRAM_CONNECTIONS : "has" + USERS ||--o| TIKTOK_CONNECTIONS : "has" USERS ||--o{ HISTORY : "creates" - USERS ||--o{ UPLOAD_HISTORY : "uploads" USERS ||--o{ USER_ASSETS : "owns" USERS ||--o{ CREDIT_HISTORY : "has" + USERS ||--o{ API_USAGE_LOGS : "generates" + + PENSION_PROFILES ||--o{ PENSION_IMAGES : "contains" + PENSION_PROFILES ||--o{ DAILY_AUTO_GENERATION : "has" PENSION_PROFILES ||--o{ YOUTUBE_PLAYLISTS : "has" - PENSION_PROFILES ||--o{ HISTORY : "related" - HISTORY ||--o{ UPLOAD_HISTORY : "uploaded_as" + PENSION_PROFILES ||--o{ AUTO_GENERATION_LOGS : "logs" + PENSION_PROFILES ||--o{ PENSION_FESTIVAL_MATCHES : "matches" + + HISTORY ||--o{ UPLOAD_HISTORY : "youtube" + HISTORY ||--o{ INSTAGRAM_UPLOAD_HISTORY : "instagram" + HISTORY ||--o{ TIKTOK_UPLOAD_HISTORY : "tiktok" + + FESTIVALS ||--o{ PENSION_FESTIVAL_MATCHES : "matched_to" USERS { int id PK string username UK string email UK - string password "bcrypt hash" - string name - string phone - string role "user/admin" - int approved + string password + string role + string plan_type int credits - string plan_type "free/basic/pro/business" int max_pensions - int monthly_credits - int storage_limit "MB" - int storage_used "bytes" - datetime createdAt + string experience_level } PENSION_PROFILES { int id PK int user_id FK - int is_default string brand_name - string brand_name_en string region string address - json pension_types - json key_features - string youtube_playlist_id - datetime createdAt + real mapx + real mapy } - USER_ASSETS { + PENSION_IMAGES { int id PK - int user_id FK int pension_id FK - string asset_type "image/audio/video" - string source_type "upload/crawl/ai_generated/rendered" - string file_path - int file_size - int is_deleted - datetime createdAt + string filename + string source + int is_priority } HISTORY { @@ -498,137 +908,482 @@ erDiagram int user_id FK int pension_id FK string business_name - json details string final_video_path - string poster_path - datetime createdAt + } + + YOUTUBE_CONNECTIONS { + int id PK + int user_id FK UK + string youtube_channel_id + string access_token + string refresh_token + } + + INSTAGRAM_CONNECTIONS { + int id PK + int user_id FK UK + string instagram_username + string encrypted_session + } + + TIKTOK_CONNECTIONS { + int id PK + int user_id FK UK + string open_id + string access_token + } + + FESTIVALS { + int id PK + string content_id UK + string title + string event_start_date + string event_end_date } ``` -### 주요 테이블 설명 +### 인덱스 목록 -| 테이블 | 설명 | -|--------|------| -| `users` | 사용자 계정 정보, 플랜, 크레딧 | -| `pension_profiles` | 펜션/숙소 정보 (다중 펜션 지원) | -| `youtube_connections` | YouTube OAuth 토큰 | -| `youtube_settings` | 업로드 기본 설정 | -| `youtube_playlists` | 플레이리스트 캐시 | -| `history` | 생성된 영상 히스토리 | -| `upload_history` | YouTube 업로드 기록 | -| `user_assets` | 사용자 에셋 (이미지, 오디오, 비디오) | -| `credit_history` | 크레딧 변동 이력 | -| `credit_requests` | 크레딧 충전 요청 | +```sql +-- 축제 검색 최적화 +CREATE INDEX idx_festivals_area ON festivals(area_code, sigungu_code); +CREATE INDEX idx_festivals_date ON festivals(event_start_date, event_end_date); +CREATE INDEX idx_festivals_coords ON festivals(mapx, mapy); + +-- 펜션 검색 최적화 +CREATE INDEX idx_public_pensions_sido ON public_pensions(sido); +CREATE INDEX idx_public_pensions_sigungu ON public_pensions(sido, sigungu); +CREATE INDEX idx_public_pensions_coords ON public_pensions(mapx, mapy); + +-- API 사용량 조회 최적화 +CREATE INDEX idx_api_usage_service ON api_usage_logs(service, createdAt); +CREATE INDEX idx_api_usage_user ON api_usage_logs(user_id, createdAt); +CREATE INDEX idx_api_usage_date ON api_usage_logs(createdAt); + +-- 펜션-축제 매칭 최적화 +CREATE INDEX idx_matches_pension ON pension_festival_matches(pension_id, pension_type); +CREATE INDEX idx_matches_festival ON pension_festival_matches(festival_id); +``` --- ## A5. API 명세 -### 인증 API (`/api/auth/*`) +> **총 150+ API 엔드포인트** +> Auth: ✅ = JWT 필요, 🔐 = Admin 필요, - = 공개 -| Method | Endpoint | 설명 | Body | -|--------|----------|------|------| -| POST | `/api/auth/register` | 회원가입 | `{username, email, password, name, phone}` | -| POST | `/api/auth/login` | 로그인 | `{username, password}` | -| GET | `/api/auth/verify-email` | 이메일 인증 | `?token=xxx` | -| POST | `/api/auth/forgot-password` | 비밀번호 재설정 요청 | `{email}` | -| POST | `/api/auth/reset-password` | 비밀번호 재설정 | `{token, newPassword}` | -| GET | `/api/auth/google` | Google OAuth 시작 | - | -| GET | `/api/auth/naver` | Naver OAuth 시작 | - | +--- -### 사용자 API (`/api/profile/*`) +### 1. 인증 API (`/api/auth/*`) + +| Method | Endpoint | 설명 | Auth | Body/Query | +|--------|----------|------|:----:|------------| +| POST | `/api/auth/register` | 회원가입 | - | `{username, email, password, name, phone}` | +| POST | `/api/auth/login` | JWT 로그인 | - | `{username, password}` | +| GET | `/api/auth/me` | 현재 사용자 정보 | ✅ | - | +| PUT | `/api/auth/profile` | 프로필 수정 | ✅ | `{name, phone}` | +| PUT | `/api/auth/change-password` | 비밀번호 변경 | ✅ | `{currentPassword, newPassword}` | +| GET | `/api/auth/verify-email` | 이메일 인증 | - | `?token=xxx` | +| POST | `/api/auth/resend-verification` | 인증메일 재발송 | - | `{email}` | +| POST | `/api/auth/forgot-password` | 비밀번호 재설정 요청 | - | `{email}` | +| POST | `/api/auth/reset-password` | 비밀번호 재설정 | - | `{token, newPassword}` | +| GET | `/api/auth/google` | Google OAuth 시작 | - | - | +| GET | `/api/auth/google/callback` | Google OAuth 콜백 | - | `?code=xxx` | +| GET | `/api/auth/naver` | Naver OAuth 시작 | - | - | +| GET | `/api/auth/naver/callback` | Naver OAuth 콜백 | - | `?code=xxx` | + +--- + +### 2. 사용자 설정 API (`/api/user/*`) | Method | Endpoint | 설명 | Auth | -|--------|----------|------|------| -| GET | `/api/profile` | 프로필 조회 | ✅ | -| PUT | `/api/profile` | 프로필 수정 | ✅ | -| PUT | `/api/profile/password` | 비밀번호 변경 | ✅ | +|--------|----------|------|:----:| +| GET | `/api/user/plan` | 현재 플랜 정보 | ✅ | +| GET | `/api/user/level` | 사용자 레벨 조회 | ✅ | +| PUT | `/api/user/level` | 사용자 레벨 변경 | ✅ | +| GET | `/api/user/auto-generation` | 자동 생성 설정 조회 | ✅ | +| PUT | `/api/user/auto-generation` | 자동 생성 설정 수정 | ✅ | +| GET | `/api/user/auto-upload-settings` | 자동 업로드 설정 조회 | ✅ | +| PUT | `/api/user/auto-upload-settings` | 자동 업로드 설정 수정 | ✅ | -### 펜션 API (`/api/pensions/*`) +--- + +### 3. 펜션 프로필 API (`/api/profile/pension/*`) | Method | Endpoint | 설명 | Auth | -|--------|----------|------|------| -| GET | `/api/pensions` | 펜션 목록 조회 | ✅ | -| GET | `/api/pensions/:id` | 펜션 상세 조회 | ✅ | -| POST | `/api/pensions` | 펜션 생성 | ✅ | -| PUT | `/api/pensions/:id` | 펜션 수정 | ✅ | -| DELETE | `/api/pensions/:id` | 펜션 삭제 | ✅ | -| GET | `/api/pensions/limit` | 생성 가능 여부 | ✅ | -| POST | `/api/pensions/:id/default` | 기본 펜션 설정 | ✅ | +|--------|----------|------|:----:| +| GET | `/api/profile/pensions` | 내 펜션 목록 | ✅ | +| GET | `/api/profile/pension` | 기본 펜션 조회 | ✅ | +| GET | `/api/profile/pension/:id` | 펜션 상세 조회 | ✅ | +| POST | `/api/profile/pension` | 펜션 생성 | ✅ | +| PUT | `/api/profile/pension/:id` | 펜션 수정 | ✅ | +| DELETE | `/api/profile/pension/:id` | 펜션 삭제 | ✅ | +| POST | `/api/profile/pension/:id/default` | 기본 펜션 설정 | ✅ | +| GET | `/api/profile/pension/:id/analytics` | 펜션 분석 데이터 | ✅ | +| GET | `/api/profile/pensions/analytics-summary` | 전체 펜션 분석 요약 | ✅ | -### YouTube API (`/api/youtube/*`) +--- + +### 4. 펜션 이미지 API (`/api/profile/pension/:id/images/*`) - v3.6.0 신규 + +| Method | Endpoint | 설명 | Auth | Query | +|--------|----------|------|:----:|-------| +| GET | `/api/profile/pension/:id/images` | 이미지 목록 | ✅ | `?source=naver\|google\|instagram\|upload` | +| POST | `/api/profile/pension/:id/images` | 이미지 업로드 | ✅ | - | +| DELETE | `/api/profile/pension/:id/images/:imageId` | 이미지 삭제 | ✅ | - | +| GET | `/api/profile/pension/:id/images/stats` | 소스별 이미지 통계 | ✅ | - | + +**이미지 소스 구분:** +- `naver` - 네이버 지도에서 크롤링 +- `google` - 구글 지도에서 크롤링 +- `instagram` - 인스타그램에서 크롤링 +- `upload` - 사용자 직접 업로드 + +--- + +### 5. 자동 생성/업로드 API | Method | Endpoint | 설명 | Auth | -|--------|----------|------|------| -| GET | `/api/youtube/status` | 연결 상태 | ✅ | +|--------|----------|------|:----:| +| GET | `/api/profile/pension/:id/auto-generation` | 자동 생성 설정 | ✅ | +| POST | `/api/profile/pension/:id/auto-generation` | 자동 생성 설정 저장 | ✅ | +| GET | `/api/profile/pension/:id/auto-generation/logs` | 자동 생성 로그 | ✅ | +| POST | `/api/profile/pension/:id/auto-generation/trigger` | 수동 트리거 | ✅ | +| PATCH | `/api/auto-generation/logs/:logId` | 로그 상태 업데이트 | ✅ | +| GET | `/api/auto-generation/scheduled` | 예약된 작업 목록 | - | +| POST | `/api/auto-upload` | 자동 업로드 실행 | ✅ | + +--- + +### 6. 렌더링 큐 API (`/api/render/*`) - v3.7.0 신규 + +> **백그라운드 렌더링 시스템**: 페이지 이동/로그아웃해도 작업 계속 진행 + +| Method | Endpoint | 설명 | Auth | Body/Response | +|--------|----------|------|:----:|---------------| +| POST | `/api/render/start` | 렌더링 작업 시작 | ✅ | `{posterBase64, audioBase64, imagesBase64, adCopy, textEffect, businessName, aspectRatio, pensionId}` → `{jobId, creditsCharged}` | +| GET | `/api/render/status/:jobId` | 작업 상태 조회 | ✅ | → `{status, progress, downloadUrl, error_message}` | +| GET | `/api/render/jobs` | 내 작업 목록 | ✅ | `?status=pending&limit=20` → `{jobs[], pendingCount}` | + +**렌더링 흐름:** +``` +1. POST /api/render/start + └─ 크레딧 체크 → 선차감 → render_jobs 저장 → jobId 반환 (즉시) + +2. Background Worker + └─ pending 작업 처리 (MAX 3개 동시) + └─ Puppeteer 녹화 + FFmpeg 합성 + └─ 진행률 DB 업데이트 + +3. GET /api/render/status/:jobId (폴링) + └─ progress: 0-100% + └─ status: completed → downloadUrl 제공 + └─ status: failed → credits_refunded (환불) +``` + +**제한 사항:** +| 제한 | 값 | 에러 코드 | +|------|-----|-----------| +| 계정당 동시 렌더링 | **1개** | `RENDER_IN_PROGRESS` (429) | +| 서버 전체 동시 렌더링 | **3개** | 큐 대기 | +| 크레딧 부족 | - | `INSUFFICIENT_CREDITS` (403) | + +--- + +### 7. 크롤링 API - v3.6.0 확장 + +#### 네이버 지도 크롤링 + +| Method | Endpoint | 설명 | Auth | Body | +|--------|----------|------|:----:|------| +| POST | `/api/naver/crawl` | 네이버 플레이스 크롤링 | - | `{url}` | + +#### 인스타그램 크롤링 - v3.6.0 신규 + +| Method | Endpoint | 설명 | Auth | Body | +|--------|----------|------|:----:|------| +| POST | `/api/instagram/crawl` | Instagram 프로필 크롤링 | - | `{url}` | + +#### Google Places API + +| Method | Endpoint | 설명 | Auth | Body | +|--------|----------|------|:----:|------| +| POST | `/api/google/places/search` | 장소 검색 | ✅ | `{query}` | +| POST | `/api/google/places/details` | 장소 상세 정보 | ✅ | `{placeId}` | +| POST | `/api/google/places/photo` | 사진 다운로드 | ✅ | `{photoReference}` | + +--- + +### 7. AI API (Gemini / Suno) + +#### Gemini AI 엔드포인트 + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| +| POST | `/api/gemini/creative-content` | 광고 카피 생성 | ✅ | +| POST | `/api/gemini/speech` | TTS 음성 합성 | ✅ | +| POST | `/api/gemini/ad-poster` | 광고 포스터 생성 (Imagen 3) | ✅ | +| POST | `/api/gemini/image-gallery` | 갤러리 이미지 생성 | ✅ | +| POST | `/api/gemini/video-background` | 비디오 배경 생성 | ✅ | +| POST | `/api/gemini/text-effect` | 텍스트 이펙트 생성 | ✅ | +| POST | `/api/gemini/filter-images` | 이미지 필터링/분류 | ✅ | +| POST | `/api/gemini/enrich-description` | 설명 보강 | ✅ | +| POST | `/api/gemini/search-business` | 비즈니스 정보 검색 | ✅ | +| POST | `/api/gemini/analyze-dna` | 펜션 DNA 분석 | ✅ | + +#### Suno AI 음악 생성 + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| +| POST | `/api/suno/generate` | AI 음악 생성 | ✅ | + +--- + +### 8. YouTube API (`/api/youtube/*`) + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| | GET | `/api/youtube/oauth/url` | OAuth URL 생성 | ✅ | +| GET | `/api/youtube/oauth/callback` | OAuth 콜백 처리 | - | +| GET | `/api/youtube/connection` | 연결 상태 조회 | ✅ | +| DELETE | `/api/youtube/connection` | 연결 해제 | ✅ | | GET | `/api/youtube/settings` | 업로드 설정 조회 | ✅ | -| PUT | `/api/youtube/settings` | 업로드 설정 수정 | ✅ | -| GET | `/api/youtube/playlists` | 플레이리스트 목록 | ✅ | +| POST | `/api/youtube/settings` | 업로드 설정 저장 | ✅ | +| GET | `/api/youtube/my-playlists` | 내 플레이리스트 | ✅ | +| POST | `/api/youtube/my-playlists` | 플레이리스트 생성 | ✅ | +| GET | `/api/youtube/pension/:pensionId/playlists` | 펜션별 플레이리스트 | ✅ | +| POST | `/api/youtube/pension/:pensionId/playlists` | 펜션 플레이리스트 연결 | ✅ | +| DELETE | `/api/youtube/pension/:pensionId/playlists/:playlistId` | 연결 해제 | ✅ | | POST | `/api/youtube/my-upload` | 영상 업로드 | ✅ | -| DELETE | `/api/youtube/disconnect` | 연결 해제 | ✅ | +| POST | `/api/youtube/upload` | 영상 업로드 (레거시) | ✅ | +| GET | `/api/youtube/upload-history` | 업로드 히스토리 | ✅ | +| POST | `/api/youtube/seo` | SEO 메타데이터 생성 | ✅ | +| GET | `/api/youtube/playlists` | 플레이리스트 목록 | ✅ | +| POST | `/api/youtube/playlists` | 플레이리스트 생성 | ✅ | +| GET | `/api/youtube/playlists/:playlistId/videos` | 플레이리스트 영상 | ✅ | +| POST | `/api/youtube/playlists/business` | 비즈니스 플레이리스트 | ✅ | +| POST | `/api/profile/pension/:id/youtube-playlist` | 펜션-플레이리스트 연결 | ✅ | +| DELETE | `/api/profile/pension/:id/youtube-playlist` | 연결 해제 | ✅ | -### TikTok API (`/api/tiktok/*`) - v3.0 신규 +--- + +### 9. Instagram API (`/api/instagram/*`) - v3.0+ 신규 | Method | Endpoint | 설명 | Auth | -|--------|----------|------|------| -| GET | `/api/tiktok/status` | TikTok 연결 상태 | ✅ | -| GET | `/api/tiktok/oauth/url` | TikTok OAuth URL 생성 | ✅ | -| GET | `/api/tiktok/oauth/callback` | OAuth 콜백 처리 | ✅ | +|--------|----------|------|:----:| +| GET | `/api/instagram/health` | 서비스 상태 확인 | - | +| POST | `/api/instagram/connect` | 계정 연결 | ✅ | +| POST | `/api/instagram/disconnect` | 연결 해제 | ✅ | +| GET | `/api/instagram/status` | 연결 상태 조회 | ✅ | +| PUT | `/api/instagram/settings` | 업로드 설정 수정 | ✅ | +| POST | `/api/instagram/upload` | Reels 업로드 | ✅ | +| GET | `/api/instagram/weekly-stats` | 주간 통계 | ✅ | +| GET | `/api/instagram/history` | 업로드 히스토리 | ✅ | + +--- + +### 10. TikTok API (`/api/tiktok/*`) - v3.0 신규 + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| +| GET | `/api/tiktok/oauth/url` | OAuth URL 생성 | ✅ | +| GET | `/api/tiktok/oauth/callback` | OAuth 콜백 처리 | - | +| GET | `/api/tiktok/status` | 연결 상태 조회 | ✅ | +| POST | `/api/tiktok/disconnect` | 연결 해제 | ✅ | | GET | `/api/tiktok/settings` | 업로드 설정 조회 | ✅ | | PUT | `/api/tiktok/settings` | 업로드 설정 수정 | ✅ | | POST | `/api/tiktok/upload` | Direct Post 업로드 | ✅ | -| POST | `/api/tiktok/upload-inbox` | Inbox/Draft 업로드 | ✅ | -| GET | `/api/tiktok/publish-status/:id` | 게시 상태 확인 | ✅ | +| POST | `/api/tiktok/upload-to-inbox` | Draft/Inbox 업로드 | ✅ | | GET | `/api/tiktok/history` | 업로드 히스토리 | ✅ | -| GET | `/api/tiktok/stats` | TikTok 통계 | ✅ | -| DELETE | `/api/tiktok/disconnect` | 연결 해제 | ✅ | +| GET | `/api/tiktok/stats` | 통계 조회 | ✅ | -### 에셋 API (`/api/user-assets/*`) +--- + +### 11. 에셋 API (`/api/user-assets/*`) | Method | Endpoint | 설명 | Auth | -|--------|----------|------|------| +|--------|----------|------|:----:| | GET | `/api/user-assets/stats` | 스토리지 통계 | ✅ | | GET | `/api/user-assets` | 에셋 목록 | ✅ | -| POST | `/api/user-assets/upload` | 파일 업로드 | ✅ | +| POST | `/api/user-assets/upload` | 파일 업로드 (다중) | ✅ | | DELETE | `/api/user-assets/:id` | 에셋 삭제 | ✅ | +| POST | `/api/assets/upload` | 에셋 업로드 (일반) | ✅ | -### 관리자 API (`/api/admin/*`) +--- + +### 12. 히스토리 API (`/api/history/*`) | Method | Endpoint | 설명 | Auth | -|--------|----------|------|------| -| GET | `/api/admin/users` | 사용자 목록 | Admin | -| PUT | `/api/admin/users/:id/approve` | 사용자 승인 | Admin | -| PUT | `/api/admin/users/:id/plan` | 플랜 변경 | Admin | -| GET | `/api/admin/stats` | 통계 조회 | Admin | -| GET | `/api/admin/system-health` | 시스템 상태 | Admin | -| GET | `/api/admin/credits/stats` | 크레딧 통계 | Admin | -| GET | `/api/admin/credits/requests` | 충전 요청 목록 | Admin | -| POST | `/api/admin/credits/adjust` | 크레딧 조정 | Admin | +|--------|----------|------|:----:| +| GET | `/api/history` | 생성 기록 목록 | ✅ | +| POST | `/api/history` | 기록 저장 | ✅ | +| DELETE | `/api/history/:id` | 기록 삭제 | ✅ | +| DELETE | `/api/history` | 일괄 삭제 | ✅ | -### 고급 통계 API (`/api/admin/analytics/*`) - v3.0 신규 +--- + +### 13. 크레딧 API (`/api/credits/*`) | Method | Endpoint | 설명 | Auth | -|--------|----------|------|------| -| GET | `/api/admin/analytics/summary` | 전체 대시보드 요약 | Admin | -| GET | `/api/admin/analytics/user-growth` | 사용자 성장 추이 | Admin | -| GET | `/api/admin/analytics/video-trend` | 영상 생성 트렌드 | Admin | -| GET | `/api/admin/analytics/platform-uploads` | 플랫폼별 업로드 통계 | Admin | -| GET | `/api/admin/analytics/credit-usage` | 크레딧 사용 분석 | Admin | -| GET | `/api/admin/analytics/plan-distribution` | 플랜 분포 | Admin | -| GET | `/api/admin/analytics/top-users` | Top 활동 사용자 | Admin | -| GET | `/api/admin/analytics/usage-pattern` | 사용 패턴 분석 | Admin | -| GET | `/api/admin/analytics/regional` | 지역별 분포 | Admin | -| GET | `/api/admin/analytics/revenue` | 수익 예측 | Admin | -| GET | `/api/admin/analytics/system-health` | 시스템 헬스 (확장) | Admin | -| GET | `/api/admin/analytics/full-report` | 전체 분석 리포트 | Admin | +|--------|----------|------|:----:| +| GET | `/api/credits` | 크레딧 잔액 조회 | ✅ | +| GET | `/api/credits/history` | 크레딧 사용 내역 | ✅ | +| POST | `/api/credits/request` | 충전 요청 | ✅ | +| GET | `/api/credits/requests` | 내 충전 요청 목록 | ✅ | -### 렌더링 API +--- + +### 14. 축제/공개 펜션 API + +#### 축제 API (`/api/festivals/*`) + +| Method | Endpoint | 설명 | Auth | Query | +|--------|----------|------|:----:|-------| +| GET | `/api/festivals` | 축제 목록 | - | `?region=&month=&keyword=` | +| GET | `/api/festivals/grouped` | 지역별 그룹화 | - | - | +| GET | `/api/festivals/:id` | 축제 상세 | - | - | +| GET | `/api/festivals/:id/nearby-pensions` | 근처 펜션 | - | - | +| GET | `/api/festivals/stats/by-region` | 지역별 통계 | - | - | +| GET | `/api/festivals/stats/by-month` | 월별 통계 | - | - | + +#### 공개 펜션 API (`/api/pensions/*`) | Method | Endpoint | 설명 | Auth | -|--------|----------|------|------| +|--------|----------|------|:----:| +| GET | `/api/pensions` | 공개 펜션 목록 | - | +| GET | `/api/pensions/:id` | 펜션 상세 | - | +| GET | `/api/pensions/:id/nearby-festivals` | 근처 축제 | - | +| GET | `/api/pensions/stats/by-region` | 지역별 통계 | - | + +--- + +### 15. 관리자 API (`/api/admin/*`) + +#### 사용자 관리 + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| +| GET | `/api/admin/users` | 사용자 목록 | 🔐 | +| GET | `/api/admin/users/:id/detail` | 사용자 상세 | 🔐 | +| POST | `/api/admin/users` | 사용자 생성 | 🔐 | +| DELETE | `/api/admin/users/:id` | 사용자 삭제 | 🔐 | +| POST | `/api/admin/approve` | 사용자 승인 | 🔐 | +| PUT | `/api/admin/users/:id/role` | 역할 변경 | 🔐 | +| PUT | `/api/admin/users/:id/plan` | 플랜 변경 | 🔐 | +| POST | `/api/admin/users/:id/reset-password` | 비밀번호 초기화 | 🔐 | +| POST | `/api/admin/users/:id/credits` | 크레딧 직접 조정 | 🔐 | + +#### 크레딧 관리 + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| +| GET | `/api/admin/credits/requests` | 충전 요청 목록 | 🔐 | +| POST | `/api/admin/credits/requests/:id/process` | 요청 처리 | 🔐 | +| GET | `/api/admin/credits/stats` | 크레딧 통계 | 🔐 | + +#### 콘텐츠 관리 + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| +| GET | `/api/admin/history` | 전체 생성 기록 | 🔐 | +| DELETE | `/api/admin/history/:id` | 기록 삭제 | 🔐 | +| DELETE | `/api/admin/history` | 기록 일괄 삭제 | 🔐 | +| GET | `/api/admin/uploads` | 업로드 현황 | 🔐 | + +#### 시스템 관리 + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| +| GET | `/api/admin/stats` | 통계 요약 | 🔐 | +| GET | `/api/admin/system-health` | 시스템 상태 | 🔐 | +| GET | `/api/admin/logs` | 시스템 로그 | 🔐 | +| GET | `/api/admin/settings` | 설정 조회 | 🔐 | +| PUT | `/api/admin/settings` | 설정 수정 | 🔐 | + +#### 쿠키 관리 - v3.6.0 신규 + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| +| GET | `/api/admin/cookies` | 쿠키 설정 조회 | 🔐 | +| PUT | `/api/admin/cookies` | 쿠키 설정 수정 | 🔐 | + +#### 데이터 동기화 + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| +| POST | `/api/admin/sync/festivals` | 축제 데이터 동기화 | 🔐 | +| POST | `/api/admin/sync/pensions` | 펜션 데이터 동기화 | 🔐 | + +--- + +### 16. 고급 통계 API (`/api/admin/analytics/*`) + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| +| GET | `/api/admin/analytics/summary` | 전체 요약 | 🔐 | +| GET | `/api/admin/analytics/user-growth` | 사용자 성장 추이 | 🔐 | +| GET | `/api/admin/analytics/video-trend` | 영상 생성 트렌드 | 🔐 | +| GET | `/api/admin/analytics/platform-uploads` | 플랫폼별 업로드 | 🔐 | +| GET | `/api/admin/analytics/credit-usage` | 크레딧 사용 분석 | 🔐 | +| GET | `/api/admin/analytics/plan-distribution` | 플랜 분포 | 🔐 | +| GET | `/api/admin/analytics/top-users` | Top 활동 사용자 | 🔐 | +| GET | `/api/admin/analytics/usage-pattern` | 사용 패턴 분석 | 🔐 | +| GET | `/api/admin/analytics/regional` | 지역별 분포 | 🔐 | +| GET | `/api/admin/analytics/revenue` | 수익 예측 | 🔐 | +| GET | `/api/admin/analytics/activity-logs` | 활동 로그 | 🔐 | +| GET | `/api/admin/analytics/system-health` | 시스템 헬스 | 🔐 | +| POST | `/api/admin/analytics/update-daily` | 일일 통계 갱신 | 🔐 | +| GET | `/api/admin/analytics/full-report` | 전체 리포트 | 🔐 | + +--- + +### 17. API 사용량/빌링 API + +#### API 사용량 (`/api/admin/api-usage/*`) + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| +| GET | `/api/admin/api-usage/stats` | 사용량 통계 | 🔐 | +| GET | `/api/admin/api-usage/logs` | 사용 로그 | 🔐 | +| GET | `/api/admin/api-usage/models` | 모델별 사용량 | 🔐 | +| GET | `/api/admin/api-usage/by-user` | 사용자별 사용량 | 🔐 | +| GET | `/api/admin/api-usage/user/:userId` | 특정 사용자 상세 | 🔐 | +| GET | `/api/admin/api-usage/monthly-report` | 월간 리포트 | 🔐 | + +#### 빌링 (`/api/admin/billing/*`) + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| +| GET | `/api/admin/billing/status` | 빌링 상태 | 🔐 | +| GET | `/api/admin/billing/dashboard` | 빌링 대시보드 | 🔐 | +| GET | `/api/admin/billing/by-service` | 서비스별 비용 | 🔐 | +| GET | `/api/admin/billing/daily` | 일별 비용 | 🔐 | +| GET | `/api/admin/billing/by-sku` | SKU별 비용 | 🔐 | +| GET | `/api/admin/billing/monthly` | 월별 비용 | 🔐 | +| GET | `/api/admin/billing/gemini` | Gemini API 비용 | 🔐 | + +--- + +### 18. 유틸리티 API + +#### AI 보조 + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| +| POST | `/api/ai/auto-description` | 자동 설명 생성 | ✅ | + +#### 프록시 + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| +| GET | `/api/proxy/image` | 이미지 프록시 | - | +| GET | `/api/proxy/audio` | 오디오 프록시 | - | + +#### 렌더링 + +| Method | Endpoint | 설명 | Auth | +|--------|----------|------|:----:| | POST | `/render` | 영상 렌더링 시작 | ✅ | -| GET | `/render/status/:jobId` | 렌더링 상태 조회 | ✅ | --- @@ -774,8 +1529,8 @@ ffmpeg -i output_video.mp4 -i audio.wav \ ### 1. 저장소 클론 ```bash -git clone https://github.com/waabaa/19-claude-saas-castad.git -cd 19-claude-saas-castad +git clone https://github.com/waabaa/19-claude-festival-castad.git +cd 19-claude-festival-castad ``` ### 2. 의존성 설치 @@ -905,8 +1660,8 @@ sudo apt install -y nodejs npm ffmpeg git python3 python3-pip sudo apt install -y chromium-browser # 3. 프로젝트 클론 -git clone https://github.com/waabaa/19-claude-saas-castad.git -cd 19-claude-saas-castad +git clone https://github.com/waabaa/19-claude-festival-castad.git +cd 19-claude-festival-castad # 4. 의존성 설치 npm install @@ -950,16 +1705,16 @@ pm2 save server { listen 80; - server_name castad.example.com; + server_name castad1.ktenterprise.net; return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; - server_name castad.example.com; + server_name castad1.ktenterprise.net; - ssl_certificate /etc/letsencrypt/live/castad.example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/castad.example.com/privkey.pem; + ssl_certificate /etc/letsencrypt/live/castad1.ktenterprise.net/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/castad1.ktenterprise.net/privkey.pem; # Frontend (정적 파일) location / { @@ -1017,7 +1772,7 @@ JWT_SECRET=your_super_secret_key_min_256_bits_random_string # 서버 설정 # ============================================ PORT=3001 -FRONTEND_URL=https://castad.example.com +FRONTEND_URL=https://castad1.ktenterprise.net # ============================================ # 이메일 서비스 (선택 - 이메일 인증용) @@ -1089,12 +1844,12 @@ INSTAGRAM_ENCRYPTION_KEY=base64_encoded_32_byte_key - 애플리케이션 유형: 웹 애플리케이션 - 승인된 JavaScript 원본: - `http://localhost:3000` (개발) - - `https://castad.example.com` (프로덕션) + - `https://castad1.ktenterprise.net` (프로덕션) - 승인된 리디렉션 URI: - `http://localhost:3001/api/auth/google/callback` - `http://localhost:3001/api/youtube/oauth/callback` - - `https://api.castad.example.com/api/auth/google/callback` - - `https://api.castad.example.com/api/youtube/oauth/callback` + - `https://api.castad1.ktenterprise.net/api/auth/google/callback` + - `https://api.castad1.ktenterprise.net/api/youtube/oauth/callback` 3. **client_secret.json 다운로드** - 다운로드한 파일을 `server/client_secret.json`으로 저장 @@ -1106,8 +1861,8 @@ INSTAGRAM_ENCRYPTION_KEY=base64_encoded_32_byte_key 3. 사용 API: 네아로 (네이버 아이디로 로그인) - 필수 권한: 이름, 이메일, 프로필 사진 4. 환경 추가: PC웹 - - 서비스 URL: `https://castad.example.com` - - Callback URL: `https://api.castad.example.com/api/auth/naver/callback` + - 서비스 URL: `https://castad1.ktenterprise.net` + - Callback URL: `https://api.castad1.ktenterprise.net/api/auth/naver/callback` 5. Client ID, Secret을 `.env`에 저장 ### TikTok for Developers 설정 @@ -1136,7 +1891,7 @@ INSTAGRAM_ENCRYPTION_KEY=base64_encoded_32_byte_key - `video.upload` - **Redirect URI**: - `http://localhost:3001/api/tiktok/oauth/callback` (개발) - - `https://api.castad.example.com/api/tiktok/oauth/callback` (프로덕션) + - `https://api.castad1.ktenterprise.net/api/tiktok/oauth/callback` (프로덕션) #### 5. 환경 변수 설정 ```bash @@ -1246,7 +2001,7 @@ SUNO_API_KEY=your_suno_api_key ### 접속 정보 -- **URL**: `https://castad.example.com/admin` +- **URL**: `https://castad1.ktenterprise.net/admin` - **초기 계정**: `admin` / `admin123` > ⚠️ **중요**: 첫 로그인 후 반드시 비밀번호를 변경하세요! @@ -1259,8 +2014,10 @@ SUNO_API_KEY=your_suno_api_key | **회원 관리** | 👥 | 사용자 목록, 승인, 역할/플랜 변경 | | **크레딧** | 💰 | 크레딧 요청 처리, 수동 조정 | | **콘텐츠** | 🎬 | 생성된 영상 목록, 삭제 관리 | +| **업로드 현황** | 📤 | YouTube/TikTok/Instagram 업로드 통계 | | **활동 로그** | 📋 | 사용자 활동 기록 | -| **시스템** | ⚙️ | 서버 상태, 외부 서비스 상태 | +| **API 사용량** | 📈 | Gemini/Suno API 사용량 및 비용 | +| **설정** | ⚙️ | 시스템 설정, 쿠키 관리 (v3.6.0) | ### 개요 화면 정보 @@ -1362,7 +2119,80 @@ SUNO_API_KEY=your_suno_api_key --- -## B8. 시스템 모니터링 +## B8. 사진 관리 시스템 + +CaStAD는 다양한 소스에서 펜션 사진을 수집하고 관리할 수 있는 종합 사진 관리 시스템을 제공합니다. + +### 지원 소스 (Image Sources) + +| 소스 | 설명 | 설정 방법 | +|------|------|----------| +| **네이버 지도** | 네이버 플레이스에서 사진 크롤링 | 네이버 쿠키 설정 (선택) | +| **구글 지도** | 구글 플레이스에서 사진 크롤링 | Google API 키 설정 | +| **인스타그램** | 인스타그램 프로필에서 사진 크롤링 | 인스타그램 쿠키 설정 (필수) | +| **직접 업로드** | 사용자가 직접 업로드한 사진 | - | + +### 크롤링 사용법 + +1. **펜션 등록 시 자동 크롤링**: + - 네이버: `https://naver.me/...` 또는 `https://map.naver.com/...` + - 구글: `https://maps.google.com/...` 또는 `https://goo.gl/maps/...` + - 인스타: `https://instagram.com/username` + +2. **소스별 자동 분류**: + - 크롤링된 이미지는 자동으로 소스가 구분되어 저장됩니다 + - 파일명에 소스 접두사 포함: `naver_`, `google_`, `instagram_` + +### 쿠키 관리 (관리자) + +관리자 대시보드 → **설정** → **API 쿠키 관리** + +``` +┌─────────────────────────────────────────┐ +│ API 쿠키 관리 │ +├─────────────────────────────────────────┤ +│ 네이버 지도 쿠키 │ +│ [NNB=xxx; JSESSIONID=xxx; ...] [수정] │ +│ 마지막 수정: 2024-01-15 14:30 │ +│ │ +│ 인스타그램 쿠키 │ +│ [sessionid=xxx; csrftoken=xxx; ...][수정]│ +│ 마지막 수정: 2024-01-15 14:30 │ +└─────────────────────────────────────────┘ +``` + +**쿠키 획득 방법**: +1. 브라우저에서 해당 서비스에 로그인 +2. 개발자 도구 (F12) → Application → Cookies +3. 모든 쿠키를 `이름=값; 이름=값; ...` 형식으로 복사 +4. 관리자 대시보드에서 저장 + +### 이미지 소스별 API + +```bash +# 전체 이미지 조회 +GET /api/profile/pension/:id/images + +# 소스별 필터링 +GET /api/profile/pension/:id/images?source=naver +GET /api/profile/pension/:id/images?source=google +GET /api/profile/pension/:id/images?source=instagram +GET /api/profile/pension/:id/images?source=upload + +# 소스별 통계 +GET /api/profile/pension/:id/images/stats +# 응답: { total: 50, naver: 20, google: 15, instagram: 10, upload: 5 } +``` + +### 주의사항 + +- **쿠키 만료**: 로그인 세션 쿠키는 일정 기간 후 만료됩니다. 크롤링 실패 시 쿠키를 다시 설정하세요. +- **인스타그램 제한**: 인스타그램은 비로그인 상태에서 접근이 제한됩니다. 반드시 쿠키를 설정하세요. +- **API 호출 제한**: 과도한 크롤링은 차단될 수 있습니다. 적절한 간격을 두고 사용하세요. + +--- + +## B9. 시스템 모니터링 ### 시스템 상태 확인 @@ -1408,7 +2238,7 @@ pm2 stop castad-server --- -## B9. 백업 및 복구 +## B10. 백업 및 복구 ### 데이터베이스 백업 @@ -1470,7 +2300,7 @@ pm2 restart castad-server --- -## B10. 트러블슈팅 +## B11. 트러블슈팅 ### 자주 발생하는 오류 @@ -1672,17 +2502,28 @@ CastAD는 **AI가 자동으로 펜션 홍보 영상을 만들어주는 서비스 4. **"저장"** 버튼 클릭 -#### 방법 2: 지도 URL로 자동 입력 +#### 방법 2: URL로 자동 입력 (v3.6.0 확장) -네이버 지도나 구글 지도 URL을 입력하면 정보가 자동으로 채워집니다! +네이버 지도, 구글 지도, 또는 인스타그램 URL을 입력하면 정보가 자동으로 채워집니다! + +**지원하는 URL 형식:** + +| 소스 | URL 예시 | +|------|----------| +| **네이버 지도** | `https://naver.me/...` 또는 `https://map.naver.com/...` | +| **구글 지도** | `https://maps.google.com/...` 또는 `https://goo.gl/maps/...` | +| **인스타그램** | `https://instagram.com/username` 또는 `https://www.instagram.com/username` | + +**사용 방법:** 1. **"새 펜션 추가"** 클릭 -2. **"지도 URL로 가져오기"** 탭 선택 -3. 네이버 지도 또는 구글 지도에서 펜션 검색 -4. 주소창의 URL 복사 -5. URL 입력 후 **"정보 가져오기"** 클릭 -6. 자동으로 채워진 정보 확인 및 수정 -7. **"저장"** 클릭 +2. **"URL로 가져오기"** 탭 선택 +3. 원하는 URL 붙여넣기 +4. **"정보 가져오기"** 클릭 (URL이 자동으로 인식됩니다) +5. 자동으로 채워진 정보 확인 및 수정 +6. **"저장"** 클릭 + +> **인스타그램 크롤링 참고**: 인스타그램에서 사진을 가져오려면 관리자가 인스타그램 쿠키를 설정해야 합니다. 관리자에게 문의하세요. ### 펜션 수정 및 삭제 @@ -2736,9 +3577,357 @@ SOM (초기 목표): 500개 × 5만원 × 12개월 = 3억원/년 --- +## 🏗️ 시스템 아키텍처 + +### 현재 아키텍처 (Single Server) + +

+ Single Server Architecture +

+ +**구성 요소:** +- **Frontend**: React 19 + Vite (Port 5173) +- **Backend**: Express.js (Port 3001) - Auth, Render, API Proxy Services +- **Database**: SQLite (File-based) +- **Storage**: Local Filesystem (downloads/, temp/, uploads/) +- **External APIs**: Gemini (AI), Suno (Music), YouTube (Upload) + +### 대규모 분산 아키텍처 (Scale-Out) + +

+ Distributed Architecture +

+ +**핵심 구성 요소:** +- **Load Balancer**: nginx / AWS ALB - 트래픽 분산 +- **API Servers**: 다중 Express.js 인스턴스 (수평 확장) +- **Redis**: 세션, 캐시, 작업 큐 (Bull Queue) +- **PostgreSQL**: Primary + Read Replica 구성 +- **S3/MinIO**: 영상, 이미지, 에셋 저장소 +- **Render Workers**: Puppeteer + FFmpeg (GPU 노드 권장) +- **Upload Workers**: YouTube, Instagram, TikTok 병렬 업로드 + +### Docker Compose 구성 (개발/소규모) + +```yaml +# docker-compose.yml +version: '3.8' + +services: + # Nginx Load Balancer + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + depends_on: + - api1 + - api2 + + # API Servers + api1: + build: . + environment: + - NODE_ENV=production + - REDIS_URL=redis://redis:6379 + - DATABASE_URL=postgresql://user:pass@postgres:5432/castad + - S3_ENDPOINT=http://minio:9000 + depends_on: + - redis + - postgres + + api2: + build: . + environment: + - NODE_ENV=production + - REDIS_URL=redis://redis:6379 + - DATABASE_URL=postgresql://user:pass@postgres:5432/castad + - S3_ENDPOINT=http://minio:9000 + depends_on: + - redis + - postgres + + # Render Workers + render-worker: + build: + context: . + dockerfile: Dockerfile.render + deploy: + replicas: 3 + environment: + - REDIS_URL=redis://redis:6379 + - S3_ENDPOINT=http://minio:9000 + + # Upload Workers + upload-worker: + build: + context: . + dockerfile: Dockerfile.upload + deploy: + replicas: 2 + environment: + - REDIS_URL=redis://redis:6379 + + # Redis + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + command: redis-server --appendonly yes + + # PostgreSQL + postgres: + image: postgres:15-alpine + environment: + - POSTGRES_USER=user + - POSTGRES_PASSWORD=pass + - POSTGRES_DB=castad + volumes: + - postgres_data:/var/lib/postgresql/data + + # MinIO (S3-compatible) + minio: + image: minio/minio + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + command: server /data --console-address ":9001" + +volumes: + redis_data: + postgres_data: + minio_data: +``` + +### Kubernetes 구조 (대규모) + +

+ Kubernetes Architecture +

+ +**Kubernetes 리소스:** +- **Ingress Controller**: nginx-ingress / Traefik (TLS 종료) +- **Deployments with HPA**: + - API Server: 2-10 pods (CPU 기반 auto-scaling) + - Render Worker: 3-20 pods (GPU 노드, 큐 길이 기반) + - Upload Worker: 2-5 pods +- **StatefulSets**: PostgreSQL (Primary + Replicas), Redis Cluster (3-node) +- **External Services**: AWS S3, Gemini API, Suno API, YouTube/Instagram/TikTok APIs + +### 마이그레이션 로드맵 + +| 단계 | 규모 | 주요 변경 | 예상 동시 사용자 | +|------|------|----------|-----------------| +| **1단계** | 현재 | Single Server + SQLite | ~50명 | +| **2단계** | 소규모 | Docker Compose + PostgreSQL + Redis | ~500명 | +| **3단계** | 중규모 | Kubernetes (3노드) + S3 | ~5,000명 | +| **4단계** | 대규모 | Multi-AZ K8s + Auto Scaling | ~50,000명+ | + +### 각 단계별 주요 작업 + +#### 2단계 (Docker Compose) +- [ ] SQLite → PostgreSQL 마이그레이션 +- [ ] Redis 도입 (세션, 캐시, 작업 큐) +- [ ] Bull Queue로 렌더링 작업 분리 +- [ ] MinIO로 파일 저장소 분리 +- [ ] Docker 이미지 빌드 및 compose 구성 + +#### 3단계 (Kubernetes) +- [ ] Helm Chart 작성 +- [ ] HPA(Horizontal Pod Autoscaler) 설정 +- [ ] PersistentVolume 구성 +- [ ] Ingress 설정 (TLS 포함) +- [ ] 모니터링 (Prometheus + Grafana) + +#### 4단계 (Multi-AZ) +- [ ] Multi-AZ PostgreSQL (RDS 또는 Cloud SQL) +- [ ] Redis Cluster (ElastiCache 또는 MemoryStore) +- [ ] CDN 설정 (CloudFront 또는 Cloud CDN) +- [ ] 글로벌 로드밸런싱 + +--- + ## 📅 버전 히스토리 -### v3.1.0 - Custard Branding + Complete Manuals (Current) +### v3.7.0 - Background Render Queue & Platform Upload (Current) + +![Render Queue Architecture](public/images/render-queue-architecture.svg) + +- **🔄 백그라운드 렌더링 큐 시스템**: + - 페이지 이동/로그아웃해도 렌더링 작업 계속 진행 + - 작업 시작 시 크레딧 **선차감**, 실패 시 **자동 환불** + - 계정당 동시 렌더링 **1개** 제한 (서버 전체 **3개**) + - 실시간 진행률 폴링 및 완료 알림 + +| API | 설명 | +|-----|------| +| `POST /api/render/start` | 렌더링 작업 시작, jobId 즉시 반환 | +| `GET /api/render/status/:jobId` | 작업 상태/진행률 조회 | +| `GET /api/render/jobs` | 내 작업 목록 조회 | + +- **📱 Instagram 업로드 기능**: + - ResultPlayer에 Instagram 업로드 버튼 추가 + - LibraryView 드롭다운에 YouTube/Instagram 업로드 옵션 + - 계정 미연결 시 새 탭에서 설정 페이지 안내 + +- **💾 새 DB 테이블**: `render_jobs` + ```sql + render_jobs ( + id TEXT PRIMARY KEY, -- job_xxx_xxx + user_id, pension_id, + status TEXT, -- pending|processing|completed|failed + progress INTEGER, -- 0-100 + input_data TEXT, -- JSON + output_path TEXT, + credits_charged INTEGER, + credits_refunded INTEGER, + created_at, started_at, completed_at + ) + ``` + +- **🔧 프론트엔드 개선**: + - ResultPlayer: 큐 기반 렌더링, 폴링, "페이지를 나가도 렌더링 계속" 메시지 + - LibraryView: YouTube/Instagram 업로드 버튼 (DropdownMenu) + +### v3.6.0 - Instagram Crawling & Photo Management +- **📸 인스타그램 사진 크롤링**: + - 인스타그램 프로필 URL 입력 시 자동으로 사진 수집 + - 쿠키 기반 인증으로 공개/팔로우 계정 접근 + - 캐러셀 이미지 지원 (여러 장 게시물) +- **🔑 관리자 쿠키 관리**: + - 관리자 대시보드 > 설정에서 API 쿠키 관리 + - 네이버 지도 쿠키 / 인스타그램 쿠키 저장 + - DB 저장으로 서버 재시작 후에도 유지 +- **🗂️ 사진 소스별 분류**: + - 크롤링 시 소스 자동 구분: `naver` / `google` / `instagram` / `upload` + - 소스별 필터링 API 지원 (`?source=naver`) + - 소스별 통계 조회 API (`/images/stats`) +- **🔧 네이버 크롤링 안정화**: + - 네이버 GraphQL API 변경 대응 + - 지원되지 않는 필드 제거 (menuImages, bizImages 등) + - `images` + `cpImages`만 사용하도록 수정 + +### v3.5.0 - Enhanced Image Upload & Extended Capacity +- **📸 이미지 업로드 안정화**: + - `useRef` 기반 파일 입력으로 브라우저 호환성 개선 + - 파일 선택 후 업로드 안 되는 버그 수정 + - 호버 효과 추가로 UX 개선 +- **🖼️ 이미지 용량 대폭 확대** (15장 → 100장): + - 네이버 플레이스 크롤링: 최대 100장 + - 구글 지도 크롤링: 최대 100장 + - 수동 업로드: 최대 100장 +- **⭐ 업로드 이미지 우선순위**: + - 수동 업로드한 이미지가 목록 최상단에 배치 + - 업로드된 이미지 자동 선택 + - 랜덤 선택 시에도 업로드 이미지 우선 포함 +- **🌐 다국어 지원 업데이트**: + - 한국어, 영어, 일본어, 중국어, 태국어, 베트남어 번역 업데이트 + - "최대 100장" 텍스트 전 언어 반영 +- **🔧 Google OAuth 프로덕션 설정**: + - `BACKEND_URL` 환경변수 추가 필요 + - 프로덕션 리다이렉션 URI 가이드 문서화 + +### v3.4.0 - Business DNA & Smart Image Management +- **🧬 비즈니스 DNA 분석 시스템**: Google Search Grounding + Gemini 2.5 Flash + - 펜션 이름/URL 입력 시 AI가 자동으로 브랜드 DNA 분석 + - **톤앤매너 (Tone & Manner)**: 브랜드 커뮤니케이션 스타일 추출 + - **타겟 고객 (Target Customers)**: 주요/부차 타겟층, 연령대, 특성 분석 + - **브랜드 컬러 (Brand Colors)**: 이미지 기반 컬러 팔레트 자동 추출 + - **키워드 & 해시태그**: SEO/SNS 최적화 키워드 자동 생성 + - **시각적 스타일**: 인테리어, 외관, 분위기, 추천 사진 스타일 + - **차별화 포인트 (USP)**: 경쟁력 있는 고유 가치 제안 + - DNACard 컴포넌트로 시각적 결과 표시 +- **📸 스마트 이미지 크롤링 강화**: + - 네이버 플레이스 이미지 최대 100장 크롤링 (v3.5.0에서 확대) + - 다양한 이미지 소스 수집 (menuImages, bizImages, roomImages, fsasImages) + - 이미지 셔플 및 중복 제거 알고리즘 적용 +- **💾 펜션 이미지 영구 저장 시스템**: + - 크롤링/업로드된 이미지를 펜션 프로필에 자동 저장 + - `pension_images` 테이블로 이미지 메타데이터 관리 + - 펜션 선택 시 저장된 이미지 자동 로드 + - 이미지 추가/삭제 API 엔드포인트 +- **🎯 이미지 선택 UI 개선** (Beginner Mode): + - 전체 선택 / 개별 선택 / 랜덤 선택 모드 + - 체크박스 기반 직관적 이미지 선택 + - 선택된 이미지만으로 영상 생성 +- **📊 API 엔드포인트 추가**: + - `POST /api/profile/pension/:id/images` - 이미지 저장 + - `GET /api/profile/pension/:id/images` - 이미지 목록 조회 + - `DELETE /api/profile/pension/:id/images/:imageId` - 이미지 삭제 + - `POST /api/gemini/analyze-dna` - DNA 분석 + +### v3.3.0 - UX Level System +- **🎯 사용자 레벨 시스템**: 3단계 UX 레벨 도입 + - **쌩초보 (Beginner)**: "다 알아서 해줘" - 최소한의 옵션, 원클릭 생성 + - **중급 (Intermediate)**: "조금만 선택할게" - 장르/언어 선택, 기본 커스터마이징 + - **프로 (Pro)**: "내가 다 컨트롤" - 모든 옵션 제어, 축제 연동 +- **🤖 자동 업로드 시스템**: 레벨에 따른 자동 업로드 제어 + - Beginner/Intermediate: 강제 자동 업로드 (YouTube, Instagram, TikTok) + - Pro: 수동/자동 선택 가능 + - AI 기반 SEO 데이터 자동 생성 (제목, 설명, 태그, 해시태그) +- **⏰ 주간 자동 생성 스케줄러**: node-cron 기반 예약 생성 + - 요일/시간 선택 가능 + - 자동 큐 관리 및 순차 처리 + - 실패 시 자동 재시도 +- **🎨 조건부 UI 렌더링**: 레벨에 따른 메뉴 및 옵션 표시 + - Sidebar 메뉴 필터링 + - NewProjectView 단계 간소화 (Beginner: 2단계, Pro: 4단계) + - SettingsView에 레벨 변경 UI 추가 +- **📊 데이터베이스 확장**: + - `experience_level` 컬럼 추가 + - `auto_generation_settings` 테이블 (자동 생성 설정) + - `generation_queue` 테이블 (작업 큐) + - `upload_history` 테이블 (업로드 이력) + +### v3.2.1 - Instagram Service & Admin Dashboard Fixes +- **🔧 Instagram 서비스 안정화**: + - None 값 처리 개선 (`NoneType` has no attribute `strip` 에러 해결) + - 디버그 로깅 추가로 문제 진단 용이 + - instagrapi 라이브러리 2.1.2 → 2.2.1 업그레이드 +- **🚀 start.sh 개선**: + - Python 캐시 자동 삭제 (`.pyc`, `__pycache__`) + - 코드 변경 시 서비스 재시작 없이 즉시 반영 +- **🛡️ AdminDashboard 권한 체크 수정**: + - 사용자 정보 로딩 완료 후 권한 체크하도록 수정 + - 관리자 로그인 후 `/admin` 접근 시 리다이렉트 문제 해결 + +### v3.2.0 - Festival Integration + Billing & Analytics +- **🎪 축제 연동 시스템**: 한국관광공사 축제 데이터 통합 + - 실시간 축제 정보 조회 및 자동 동기화 + - 행정구역별 축제 그룹핑 (시/도 기준) + - 펜션 위치 기반 근처 축제 자동 추천 + - 선택한 축제 정보가 영상 콘텐츠에 자동 반영 +- **💰 Google Cloud Billing 연동**: BigQuery 기반 실시간 비용 추적 + - 서비스별/SKU별 비용 상세 분석 + - 일별/월별 비용 트렌드 차트 + - Gemini API 사용량 별도 집계 + - USD/KRW 환율 자동 적용 +- **📊 API 사용량 추적 시스템**: 서비스별 사용량 모니터링 + - Gemini, Suno, YouTube 등 서비스별 사용량 집계 + - 사용자별 API 호출 추적 (과금 대비) + - 모델별 성공/실패율 및 평균 지연시간 + - 월별 사용량 리포트 자동 생성 +- **🎯 AI 모델 우선순위 Fallback 시스템**: 지능적 모델 선택 + - Gemini 2.0 Flash → Gemini 2.5 Flash → Imagen 3 자동 전환 + - Rate Limit (429) 또는 서버 오류 (500) 시 자동 Fallback + - 모델별 비용 추정 및 최적화 +- **✨ AI 매직 라이트**: 펜션 컨셉 자동 생성 + - 펜션 정보 + 카테고리 + 축제 정보 기반 AI 작문 + - 2-3문장 매력적인 마케팅 컨셉 자동 생성 +- **🔐 개별 YouTube OAuth 2.0**: 사용자별 채널 연동 완성 + - 각 사용자가 자신의 YouTube 채널 연결 + - 연결된 채널로 직접 업로드 + - 연결 상태 관리 및 토큰 자동 갱신 +- **버그 수정**: + - Gemini API 500 오류 시 모델 자동 전환 + - YouTube 업로드 시 명확한 연결 상태 확인 + - 축제 데이터 날짜 포맷 정규화 + +### v3.1.0 - Custard Branding + Complete Manuals - **커스터드(Custard) 브랜딩**: 🍮 테마로 전체 리브랜딩 - CaStAD = Custard (카스타드) 철학 적용 - SVG 로고: 라메킨에 담긴 커스터드 푸딩 + 스팀 애니메이션 diff --git a/components/ResultPlayer.tsx b/components/ResultPlayer.tsx index a498e58..e6d2bb7 100644 --- a/components/ResultPlayer.tsx +++ b/components/ResultPlayer.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; 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 ShareModal from './ShareModal'; import SlideshowBackground from './SlideshowBackground'; @@ -55,6 +55,16 @@ const ResultPlayer: React.FC = ({ assets, onReset, autoPlay = const [showShareModal, setShowShareModal] = useState(false); const [showSEOPreview, setShowSEOPreview] = useState(false); + // Instagram 업로드 상태 + const [isUploadingInstagram, setIsUploadingInstagram] = useState(false); + const [instagramUploadStatus, setInstagramUploadStatus] = useState(''); + const [instagramUrl, setInstagramUrl] = useState(null); + + // 렌더링 큐 상태 + const [currentJobId, setCurrentJobId] = useState(null); + const [renderStatus, setRenderStatus] = useState<'idle' | 'submitting' | 'processing' | 'completed' | 'failed'>('idle'); + const [renderMessage, setRenderMessage] = useState(''); + // AutoPlay 로직 useEffect(() => { const video = videoRef.current; @@ -199,49 +209,118 @@ const ResultPlayer: React.FC = ({ assets, onReset, autoPlay = return () => clearInterval(interval); }, [isPlaying, assets.adCopy.length]); + // URL을 Base64로 변환하는 유틸리티 함수 + const urlToBase64 = async (url: string): Promise => { + if (!url) return undefined; + try { + let response: Response; + if (url.startsWith('blob:')) { + response = await fetch(url); + } else if (url.startsWith('http')) { + response = await fetch(`/api/proxy/audio?url=${encodeURIComponent(url)}`); + } else { + return undefined; + } + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + const result = reader.result as string; + resolve(result.split(',')[1]); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } catch (e) { + console.error('URL to Base64 failed:', e); + return undefined; + } + }; + + // 렌더링 작업 상태 폴링 + const pollJobStatus = async (jobId: string) => { + const token = localStorage.getItem('token'); + let pollCount = 0; + const maxPolls = 180; // 최대 3분 (1초 간격) + + const poll = async () => { + if (pollCount >= maxPolls) { + setRenderStatus('failed'); + setRenderMessage('렌더링 시간 초과'); + setIsServerDownloading(false); + setIsRendering(false); + return; + } + + try { + const res = await fetch(`/api/render/status/${jobId}`, { + headers: { 'Authorization': token ? `Bearer ${token}` : '' } + }); + const data = await res.json(); + + if (!data.success) { + throw new Error(data.error); + } + + const { job } = data; + setDownloadProgress(job.progress || 0); + + if (job.status === 'completed') { + setRenderStatus('completed'); + setRenderMessage('렌더링 완료!'); + setLastProjectFolder(job.downloadUrl?.split('/')[2] || null); + setIsServerDownloading(false); + setIsRendering(false); + + // 자동 다운로드 + if (job.downloadUrl) { + const a = document.createElement('a'); + a.href = job.downloadUrl; + a.download = `CastAD_${assets.businessName}_Final.mp4`; + document.body.appendChild(a); + a.click(); + a.remove(); + } + return; + } + + if (job.status === 'failed') { + setRenderStatus('failed'); + setRenderMessage(job.error_message || '렌더링 실패'); + setIsServerDownloading(false); + setIsRendering(false); + alert(`렌더링 실패: ${job.error_message || '알 수 없는 오류'}\n크레딧이 환불되었습니다.`); + return; + } + + // 계속 폴링 + pollCount++; + setTimeout(poll, 1000); + + } catch (error: any) { + console.error('폴링 오류:', error); + pollCount++; + setTimeout(poll, 2000); // 오류 시 2초 후 재시도 + } + }; + + poll(); + }; + const handleServerDownload = async () => { if (isServerDownloading) return; setIsServerDownloading(true); - setDownloadProgress(10); - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 300000); + setRenderStatus('submitting'); + setRenderMessage('렌더링 요청 중...'); + setDownloadProgress(5); try { - const urlToBase64 = async (url: string): Promise => { - if (!url) return undefined; - try { - // blob URL이든 외부 URL이든 모두 처리 - let response: Response; - if (url.startsWith('blob:')) { - response = await fetch(url); - } else if (url.startsWith('http')) { - // 외부 URL은 프록시를 통해 가져옴 (CORS 우회) - response = await fetch(`/api/proxy/audio?url=${encodeURIComponent(url)}`); - } else { - return undefined; - } - const blob = await response.blob(); - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => { - const result = reader.result as string; - resolve(result.split(',')[1]); - }; - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - } catch (e) { - console.error('Audio URL to Base64 failed:', e); - return undefined; - } - }; - - setDownloadProgress(20); + // 데이터 준비 + setDownloadProgress(10); const posterBase64 = await urlToBase64(assets.posterUrl); const audioBase64 = await urlToBase64(assets.audioUrl); - setDownloadProgress(30); + setDownloadProgress(15); let imagesBase64: string[] = []; if (assets.images && assets.images.length > 0) { imagesBase64 = await Promise.all( @@ -257,64 +336,72 @@ const ResultPlayer: React.FC = ({ assets, onReset, autoPlay = ); } - setDownloadProgress(40); + setDownloadProgress(20); + const payload = { - ...assets, - historyId: assets.id, posterBase64, 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 response = await fetch('/render', { + + // 렌더링 작업 시작 요청 + const response = await fetch('/api/render/start', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' }, - body: JSON.stringify(payload), - signal: controller.signal + body: JSON.stringify(payload) }); - setDownloadProgress(80); + const data = await response.json(); - if (!response.ok) { - const err = await response.text(); - throw new Error(`서버 오류: ${err}`); + if (!response.ok || !data.success) { + // 이미 진행 중인 작업이 있는 경우 + 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'); - if (folderNameHeader) { - setLastProjectFolder(decodeURIComponent(folderNameHeader)); - } + // 작업 ID 저장 및 폴링 시작 + setCurrentJobId(data.jobId); + setRenderStatus('processing'); + setRenderMessage(`렌더링 중... (크레딧 ${data.creditsCharged} 차감)`); + setIsRendering(true); + setDownloadProgress(25); - setDownloadProgress(90); - const blob = await response.blob(); - 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(); + // 상태 폴링 시작 + pollJobStatus(data.jobId); - setDownloadProgress(100); } catch (e: any) { console.error(e); - if (e.name === 'AbortError') { - alert("영상 생성 시간 초과 (5분). 서버 부하가 높거나 네트워크 문제일 수 있습니다."); - } else { - alert("영상 생성 실패: 서버가 실행 중인지 확인해주세요."); - } - } finally { - clearTimeout(timeoutId); + setRenderStatus('failed'); + setRenderMessage(e.message || '렌더링 요청 실패'); setIsServerDownloading(false); setIsRendering(false); - setTimeout(() => setDownloadProgress(0), 2000); + setDownloadProgress(0); + + if (e.message?.includes('크레딧')) { + alert(e.message); + } else { + alert(`렌더링 요청 실패: ${e.message}`); + } } }; @@ -356,49 +443,35 @@ const ResultPlayer: React.FC = ({ assets, onReset, autoPlay = // 비디오 경로 생성 const videoPath = `downloads/${lastProjectFolder}/final.mp4`; - let response; - - if (connData.connected) { - // 사용자 채널에 업로드 (새 API) - setUploadStatus("내 채널에 업로드 중..."); - response = await fetch('/api/youtube/my-upload', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '' - }, - body: JSON.stringify({ - videoPath, - seoData: { title, description, tags, pinnedComment }, - historyId: assets.id, - categoryId - }) - }); - } else { - // 레거시 업로드 (시스템 채널) - setUploadStatus("시스템 채널에 업로드 중..."); - response = await fetch('/api/youtube/upload', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '' - }, - body: JSON.stringify({ - videoPath, - seoData: { title, description, tags }, - categoryId - }) - }); + // YouTube 연결 확인 + if (!connData.connected) { + throw new Error("YouTube 계정이 연결되지 않았습니다. 설정에서 YouTube 계정을 먼저 연결해주세요."); } + // 사용자 채널에 업로드 + setUploadStatus("내 채널에 업로드 중..."); + const response = await fetch('/api/youtube/my-upload', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token ? `Bearer ${token}` : '' + }, + body: JSON.stringify({ + videoPath, + seoData: { title, description, tags, pinnedComment }, + historyId: assets.id, + categoryId + }) + }); + if (!response.ok) { const errData = await response.json(); throw new Error(errData.error || "업로드 실패"); } const data = await response.json(); - setYoutubeUrl(data.youtubeUrl); - setUploadStatus(connData.connected ? "내 채널에 업로드 완료!" : "업로드 완료!"); + setYoutubeUrl(data.youtubeUrl || data.url); + setUploadStatus("내 채널에 업로드 완료!"); } catch (e: any) { console.error(e); @@ -409,6 +482,67 @@ const ResultPlayer: React.FC = ({ 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 () => { if (isMerging) return; @@ -896,16 +1030,75 @@ const ResultPlayer: React.FC = ({ assets, onReset, autoPlay = )} + + {/* Instagram 업로드 */} + {!instagramUrl ? ( + + + + + +

{!lastProjectFolder + ? (t('textStyle') === '자막 스타일' ? '먼저 영상을 저장하세요' : 'Save video first') + : (t('textStyle') === '자막 스타일' ? 'Instagram Reels 업로드' : 'Upload to Instagram Reels') + }

+
+
+ ) : ( +
+ + + {t('textStyle') === '자막 스타일' ? '업로드 완료' : 'Uploaded'} + +
+ )} - {/* 다운로드 진행률 */} - {downloadProgress > 0 && downloadProgress < 100 && ( + {/* 렌더링 진행률 및 상태 */} + {(downloadProgress > 0 || renderStatus === 'processing') && (
-

- {t('textStyle') === '자막 스타일' ? '렌더링 중...' : 'Rendering...'} {downloadProgress}% -

+
+ {renderStatus === 'processing' && } +

+ {renderMessage || (t('textStyle') === '자막 스타일' ? '렌더링 중...' : 'Rendering...')} {downloadProgress}% +

+
+ {renderStatus === 'processing' && ( +

+ {t('textStyle') === '자막 스타일' + ? '페이지를 나가도 렌더링은 계속됩니다' + : 'Rendering continues even if you leave'} +

+ )} +
+ )} + + {/* Instagram 업로드 상태 */} + {isUploadingInstagram && instagramUploadStatus && ( +
+
+ +

{instagramUploadStatus}

+
)} diff --git a/deploy.sh b/deploy.sh old mode 100644 new mode 100755 index 8b5ad18..78ac3c1 --- a/deploy.sh +++ b/deploy.sh @@ -1,156 +1,175 @@ #!/bin/bash +# +# CaStAD 배포 스크립트 +# 로컬에서 castad1.ktenterprise.net으로 배포 +# -# 색상 정의 +# ═══════════════════════════════════════════════════════════════ +# 설정 +# ═══════════════════════════════════════════════════════════════ +SERVER="castad1.ktenterprise.net" +SERVER_USER="castad" # 서버 사용자 +REMOTE_PATH="/home/${SERVER_USER}/castad" # 서버 경로 +LOCAL_PATH="$(cd "$(dirname "$0")" && pwd)" + +# 색상 GREEN='\033[0;32m' -BLUE='\033[0;34m' +CYAN='\033[0;36m' YELLOW='\033[1;33m' RED='\033[0;31m' -NC='\033[0m' # No Color +BOLD='\033[1m' +NC='\033[0m' -echo -e "${BLUE}=== BizVibe 배포 자동화 스크립트 ===${NC}" +log() { echo -e "${GREEN}[Deploy]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; } +info() { echo -e "${CYAN}[INFO]${NC} $1"; } -# 1. 현재 경로 확인 -PROJECT_ROOT=$(pwd) -DIST_PATH="$PROJECT_ROOT/dist" -echo -e "${GREEN}[1] 현재 프로젝트 경로:${NC} $PROJECT_ROOT" +# ═══════════════════════════════════════════════════════════════ +# 빌드 +# ═══════════════════════════════════════════════════════════════ +do_build() { + log "프론트엔드 빌드 중..." + cd "$LOCAL_PATH" + npm run build + if [ $? -ne 0 ]; then + error "빌드 실패!" + exit 1 + fi + info "빌드 완료" +} -# 2. 시스템 의존성 설치 (Puppeteer/Chromium 용) -echo -e "${GREEN}[1.5] Installing Puppeteer system dependencies...${NC}" -if [ -x "$(command -v apt-get)" ]; then - sudo apt-get update && sudo apt-get install -y \ - 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_upload() { + log "서버로 파일 전송 중..." + + # 제외할 파일/폴더 + EXCLUDES="--exclude=node_modules --exclude=.git --exclude=*.log --exclude=pids --exclude=logs --exclude=server/database.sqlite --exclude=server/downloads --exclude=server/temp --exclude=.env" + + # rsync로 동기화 + rsync -avz --progress $EXCLUDES \ + "$LOCAL_PATH/" \ + "${SERVER_USER}@${SERVER}:${REMOTE_PATH}/" if [ $? -ne 0 ]; then - echo -e "${RED}❌ System dependency installation failed!${NC}" - # 의존성 설치 실패해도 일단 진행 (이미 설치된 경우 등 고려) - else - echo -e "${GREEN}✅ System dependencies installed successfully.${NC}" + error "파일 전송 실패!" + exit 1 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 -fi -echo -e "${GREEN}✅ 빌드 완료!${NC}" - -# 3. Nginx 설정 파일 생성 (경로 자동 적용) -echo -e "${GREEN}[3] Nginx 설정 파일 생성 중...${NC}" - -NGINX_CONF="nginx_bizvibe.conf" -DOMAIN_NAME="bizvibe.ktenterprise.net" # 기본 도메인 (필요 시 수정) - -cat > $NGINX_CONF < /dev/null -then - echo -e "${YELLOW}PM2가 설치되어 있지 않습니다. 설치를 시도합니다...${NC}" - npm install -g pm2 -fi + echo "=== 백엔드 의존성 설치 ===" + cd server && npm install --legacy-peer-deps --silent && cd .. -# 기존 프로세스 재시작 또는 새로 시작 -pm2 restart bizvibe-backend 2>/dev/null || pm2 start server/index.js --name "bizvibe-backend" + echo "=== 완료 ===" +REMOTE_SCRIPT -pm2 save -echo -e "${GREEN}✅ 백엔드 서버 구동 완료!${NC}" + if [ $? -ne 0 ]; then + error "서버 설치 실패!" + exit 1 + fi + info "서버 설치 완료" +} -# 5. 최종 안내 (sudo 필요 작업) -echo -e "" -echo -e "${BLUE}=== 🎉 배포 준비 완료! 남은 단계 ===${NC}" -echo -e "${YELLOW}다음 명령어를 복사하여 실행하면 Nginx 설정이 적용됩니다 (관리자 권한 필요):${NC}" -echo -e "" -echo -e "1. 설정 파일 이동:" -echo -e " ${GREEN}sudo cp $NGINX_CONF /etc/nginx/sites-available/bizvibe${NC}" -echo -e "" -echo -e "2. 사이트 활성화:" -echo -e " ${GREEN}sudo ln -s /etc/nginx/sites-available/bizvibe /etc/nginx/sites-enabled/${NC}" -echo -e "" -echo -e "3. Nginx 문법 검사 및 재시작:" -echo -e " ${GREEN}sudo nginx -t && sudo systemctl reload nginx${NC}" -echo -e "" +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 " + 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 diff --git a/docs/FESTIVAL_PENSION_INTEGRATION.md b/docs/FESTIVAL_PENSION_INTEGRATION.md new file mode 100644 index 0000000..3c5f2ee --- /dev/null +++ b/docs/FESTIVAL_PENSION_INTEGRATION.md @@ -0,0 +1,2167 @@ +# 축제-펜션 연동 시스템 설계 문서 + +> CastAD 플랫폼에 전국 축제 정보와 펜션 데이터를 연동하여, 축제 기반 숙박 마케팅 플랫폼으로 확장하는 기능 설계서 + +**작성일**: 2024-12-08 +**버전**: 1.0 +**상태**: 설계 단계 + +--- + +## 목차 + +1. [개요](#1-개요) +2. [핵심 기능](#2-핵심-기능) +3. [외부 API 연동](#3-외부-api-연동) +4. [데이터베이스 설계](#4-데이터베이스-설계) +5. [백엔드 서비스 설계](#5-백엔드-서비스-설계) +6. [API 엔드포인트](#6-api-엔드포인트) +7. [프론트엔드 설계](#7-프론트엔드-설계) +8. [AI 콘텐츠 융합](#8-ai-콘텐츠-융합) +9. [구현 로드맵](#9-구현-로드맵) +10. [기술 스택](#10-기술-스택) + +--- + +## 1. 개요 + +### 1.1 배경 + +현재 CastAD는 펜션 홍보 영상 생성에 특화된 플랫폼입니다. 여기에 전국 축제 정보를 연동하면: + +- **펜션 주인**: 인근 축제와 연계한 마케팅 콘텐츠 자동 생성 +- **여행객**: 축제 + 숙소를 한 번에 찾는 원스톱 서비스 +- **플랫폼**: 축제 시즌 트래픽 증가, 펜션 가입 유도 + +### 1.2 목표 + +1. 전국 축제 데이터 실시간 연동 (TourAPI) +2. 전국 펜션 마스터 DB 구축 (~10,000개) +3. 위치 기반 축제-펜션 자동 매칭 +4. 랜딩페이지에서 축제/펜션 노출로 가입 유도 +5. AI 콘텐츠(가사, 영상)에 축제 정보 융합 + +### 1.3 사용자 시나리오 + +``` +[시나리오 A: 축제 → 펜션] +1. 방문자가 "보령 머드축제" 검색 +2. 축제 상세페이지에서 "주변 숙소" 탭 확인 +3. 차량 30분 거리 펜션 목록 표시 +4. 펜션 클릭 → 홍보 영상 시청 → 예약 링크 이동 + +[시나리오 B: 펜션 → 축제] +1. 펜션 주인이 "가평 OO펜션" 등록 +2. 시스템이 자동으로 인근 축제 탐지 +3. "자라섬 재즈페스티벌" (10월) 매칭 +4. 홍보 영상에 "재즈도 즐기고 펜션에서 힐링~" 자동 생성 + +[시나리오 C: 랜딩페이지 가입 유도] +1. 첫 방문자가 랜딩페이지 접속 +2. "이번 달 전국 축제" 섹션 확인 +3. 관심 지역 클릭 → 해당 지역 펜션 목록 +4. "우리 펜션도 등록하기" CTA → 회원가입 유도 +``` + +--- + +## 2. 핵심 기능 + +### 2.1 기능 목록 + +| 기능 | 설명 | 우선순위 | +|-----|------|---------| +| 축제 데이터 동기화 | TourAPI에서 전국 축제 정보 수집/갱신 | P0 | +| 펜션 마스터 DB | 공공데이터 기반 전국 펜션 DB 구축 | P0 | +| 위치 기반 매칭 | GPS 좌표로 축제-펜션 거리 계산 및 매칭 | P0 | +| 지역별 펜션 목록 | 시/도/군별 펜션 검색 및 표시 | P1 | +| 축제 캘린더 | 월별/지역별 축제 일정 표시 | P1 | +| 펜션 클레임 | 펜션 주인이 본인 펜션 인증/등록 | P1 | +| AI 콘텐츠 융합 | 축제 정보를 가사/영상에 자동 반영 | P2 | +| 축제 검색 | 키워드/지역/기간별 축제 검색 | P2 | + +### 2.2 매칭 기준 + +``` +[동일 지역 매칭 - same_region] +- 같은 시/도 (예: 경기도 가평군 ↔ 경기도 가평군) +- 같은 시/군/구 + +[근거리 매칭 - nearby] +- 0~15km: "차량 15분 거리" ⭐⭐⭐ (최우선 추천) +- 15~30km: "차량 30분 거리" ⭐⭐ +- 30~50km: "차량 50분 거리" ⭐ +- 50~80km: "차량 1시간 거리" +``` + +--- + +## 3. 외부 API 연동 + +### 3.1 필요한 API 키 + +| 서비스 | 용도 | 발급처 | 비용 | +|-------|------|-------|------| +| TourAPI 4.0 | 축제/숙박 데이터 | [공공데이터포털](https://www.data.go.kr/data/15101578/openapi.do) | 무료 | +| 카카오 Local API | 주소 → 좌표 변환 | [카카오 개발자](https://developers.kakao.com/) | 무료 (일 30만건) | +| 카카오맵 API | 지도 표시 | 위와 동일 | 무료 | + +### 3.2 TourAPI 엔드포인트 + +``` +Base URL: http://apis.data.go.kr/B551011/KorService1 + +[축제/행사 관련] +GET /searchFestival - 축제 목록 조회 +GET /detailCommon - 축제 상세 정보 +GET /detailIntro - 축제 소개 정보 +GET /detailImage - 축제 이미지 + +[숙박 관련] +GET /searchStay - 숙박시설 조회 +GET /areaBasedList - 지역기반 관광정보 +GET /locationBasedList - 위치기반 관광정보 + +[코드 조회] +GET /areaCode - 지역코드 조회 +GET /sigunguCode - 시군구코드 조회 +``` + +### 3.3 TourAPI 호출 예시 + +```javascript +// 축제 조회 +const festivalParams = { + serviceKey: process.env.TOURAPI_KEY, + numOfRows: 100, + pageNo: 1, + MobileOS: 'ETC', + MobileApp: 'CastAD', + _type: 'json', + listYN: 'Y', + arrange: 'A', + eventStartDate: '20241201', // YYYYMMDD + eventEndDate: '20241231', + areaCode: '31', // 경기도 +}; + +// 숙박 조회 (펜션) +const stayParams = { + ...commonParams, + contentTypeId: '32', // 숙박 + cat3: 'B02010700', // 펜션 +}; +``` + +### 3.4 카카오 Geocoding API + +```javascript +// 주소 → 좌표 변환 +const response = await axios.get( + 'https://dapi.kakao.com/v2/local/search/address.json', + { + params: { query: '경기도 가평군 청평면 호반로 1234' }, + headers: { Authorization: `KakaoAK ${KAKAO_REST_KEY}` } + } +); + +// 응답: { x: 127.123, y: 37.456 } (경도, 위도) +``` + +### 3.5 공공데이터 - 전국 펜션 데이터 + +| 데이터셋 | URL | 형태 | +|---------|-----|------| +| 전국관광펜션업소표준데이터 | [링크](https://www.data.go.kr/data/15013105/standard.do) | CSV | +| 행정안전부 숙박업 | [링크](https://www.data.go.kr/data/15044968/fileData.do) | CSV | +| LOCALDATA | [링크](https://www.localdata.go.kr) | API | + +--- + +## 4. 데이터베이스 설계 + +### 4.1 ERD 개요 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ festivals │ │ pension_festival │ │ public_pensions │ +│ │ │ _matches │ │ │ +│ id (PK) │◄────│ festival_id │ │ id (PK) │ +│ content_id │ │ pension_id │────►│ source │ +│ title │ │ distance_km │ │ name │ +│ addr1 │ │ match_type │ │ address │ +│ mapx, mapy │ │ travel_time_min │ │ mapx, mapy │ +│ event_start │ └─────────────────┘ │ sido, sigungu │ +│ event_end │ │ is_claimed │ +│ ... │ ┌─────────────────┐ │ claimed_by │ +└─────────────────┘ │ pension_profiles │ └─────────────────┘ + │ │ ▲ + │ id (PK) │ │ + │ user_id │──────────────┘ + │ public_pension │ (연결) + │ _id (FK) │ + │ brand_name │ + │ address │ + │ mapx, mapy │ + └─────────────────┘ +``` + +### 4.2 테이블 정의 + +#### 4.2.1 festivals (축제 정보) + +```sql +CREATE TABLE festivals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- TourAPI 식별자 + content_id TEXT UNIQUE NOT NULL, + content_type_id TEXT DEFAULT '15', -- 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, -- 경도 (longitude) + mapy REAL, -- 위도 (latitude) + + -- 기간 + event_start_date TEXT, -- 시작일 (YYYYMMDD) + event_end_date TEXT, -- 종료일 (YYYYMMDD) + + -- 이미지 + 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 idx_festivals_area ON festivals(area_code, sigungu_code); +CREATE INDEX idx_festivals_date ON festivals(event_start_date, event_end_date); +CREATE INDEX idx_festivals_coords ON festivals(mapx, mapy); +CREATE INDEX idx_festivals_active ON festivals(is_active, event_end_date); +``` + +#### 4.2.2 public_pensions (전국 펜션 마스터) + +```sql +CREATE TABLE public_pensions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- 데이터 출처 + source TEXT NOT NULL, -- 'PENSION_STD' | 'TOURAPI' | 'LOCALDATA' + source_id TEXT, -- 원본 데이터 ID + content_id TEXT, -- TourAPI contentid + + -- 기본 정보 + 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, -- TourAPI 지역코드 + sigungu_code TEXT, -- TourAPI 시군구코드 + + -- 좌표 + mapx REAL, -- 경도 (longitude) + mapy REAL, -- 위도 (latitude) + + -- 연락처 + tel TEXT, -- 전화번호 + homepage TEXT, -- 홈페이지 + + -- 이미지 + thumbnail TEXT, -- 대표이미지 URL + images TEXT, -- 추가이미지 JSON 배열 + + -- 숙박 상세 (TourAPI) + checkin_time TEXT, -- 체크인 시간 + checkout_time TEXT, -- 체크아웃 시간 + room_count INTEGER, -- 객실 수 + room_type TEXT, -- 객실유형 + facilities TEXT, -- 부대시설 JSON + parking TEXT, -- 주차정보 + reservation_url TEXT, -- 예약 URL + + -- 특성 + pet_allowed INTEGER DEFAULT 0, -- 반려동물 가능 + pickup_available INTEGER DEFAULT 0, -- 픽업 가능 + cooking_available INTEGER DEFAULT 0, -- 취사 가능 + barbecue_available INTEGER DEFAULT 0, -- 바베큐 가능 + + -- 영업 상태 (LOCALDATA) + business_status TEXT DEFAULT '영업중', -- 영업중, 폐업, 휴업 + license_date TEXT, -- 인허가일 + closure_date TEXT, -- 폐업일 + + -- 소유권 + is_verified INTEGER DEFAULT 0, -- 정보 검증 완료 + is_claimed INTEGER DEFAULT 0, -- 소유자 인증 완료 + claimed_by INTEGER, -- 인증한 user_id + 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 idx_public_pensions_sido ON public_pensions(sido); +CREATE INDEX idx_public_pensions_sigungu ON public_pensions(sido, sigungu); +CREATE INDEX idx_public_pensions_coords ON public_pensions(mapx, mapy); +CREATE INDEX idx_public_pensions_name ON public_pensions(name_normalized); +CREATE INDEX idx_public_pensions_source ON public_pensions(source, source_id); +CREATE INDEX idx_public_pensions_claimed ON public_pensions(is_claimed, claimed_by); +CREATE UNIQUE INDEX idx_public_pensions_unique ON public_pensions(source, source_id); +``` + +#### 4.2.3 pension_festival_matches (펜션-축제 매칭) + +```sql +CREATE TABLE pension_festival_matches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- 연결 + pension_id INTEGER NOT NULL, -- public_pensions.id 또는 pension_profiles.id + pension_type TEXT DEFAULT 'public', -- 'public' | 'profile' + festival_id INTEGER NOT NULL, + + -- 거리 정보 + distance_km REAL, -- 직선 거리 (km) + travel_time_min INTEGER, -- 예상 이동시간 (분) + + -- 매칭 유형 + match_type TEXT, -- 'same_region' | 'nearby' + match_score INTEGER DEFAULT 0, -- 매칭 점수 (0-100) + + -- 추천 + 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 idx_matches_pension ON pension_festival_matches(pension_id, pension_type); +CREATE INDEX idx_matches_festival ON pension_festival_matches(festival_id); +CREATE INDEX idx_matches_distance ON pension_festival_matches(distance_km); +``` + +#### 4.2.4 area_codes (지역 코드) + +```sql +-- TourAPI 지역 코드 +CREATE TABLE area_codes ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, -- 서울특별시, 경기도 등 + name_short TEXT, -- 서울, 경기 등 + name_en TEXT -- Seoul, Gyeonggi 등 +); + +-- 시군구 코드 +CREATE TABLE 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) +); +``` + +#### 4.2.5 pension_claims (펜션 인증 요청) + +```sql +CREATE TABLE pension_claims ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + public_pension_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + + -- 인증 정보 + status TEXT DEFAULT 'pending', -- pending, approved, rejected + verification_method TEXT, -- phone, email, document + verification_data TEXT, -- JSON (전화번호, 사업자등록증 등) + + -- 처리 정보 + 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) +); +``` + +#### 4.2.6 pension_profiles 테이블 수정 + +```sql +-- 기존 pension_profiles에 컬럼 추가 +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); +``` + +### 4.3 지역 코드 초기 데이터 + +```sql +INSERT 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'); +``` + +--- + +## 5. 백엔드 서비스 설계 + +### 5.1 파일 구조 + +``` +server/ +├── services/ +│ ├── festivalService.js # 축제 API 연동 +│ ├── pensionDataService.js # 펜션 데이터 수집 +│ ├── matchingService.js # 축제-펜션 매칭 +│ ├── geocodingService.js # 주소-좌표 변환 +│ └── tourApiClient.js # TourAPI 클라이언트 +├── scripts/ +│ ├── syncFestivals.js # 축제 동기화 스크립트 +│ ├── syncPensions.js # 펜션 동기화 스크립트 +│ └── initAreaCodes.js # 지역코드 초기화 +├── routes/ +│ ├── festivalRoutes.js # 축제 API 라우트 +│ └── publicPensionRoutes.js # 공개 펜션 API 라우트 +└── cron/ + └── syncJob.js # 정기 동기화 작업 +``` + +### 5.2 핵심 서비스 + +#### 5.2.1 TourAPI 클라이언트 + +```javascript +// server/services/tourApiClient.js + +const axios = require('axios'); + +const BASE_URL = 'http://apis.data.go.kr/B551011/KorService1'; + +class TourApiClient { + constructor(serviceKey) { + this.serviceKey = serviceKey; + this.client = axios.create({ + baseURL: BASE_URL, + timeout: 30000, + }); + } + + async request(endpoint, params = {}) { + const response = await this.client.get(endpoint, { + params: { + serviceKey: this.serviceKey, + MobileOS: 'ETC', + MobileApp: 'CastAD', + _type: 'json', + ...params, + }, + }); + + const body = response.data.response?.body; + if (!body) { + throw new Error('Invalid API response'); + } + + return { + items: body.items?.item || [], + totalCount: body.totalCount, + pageNo: body.pageNo, + numOfRows: body.numOfRows, + }; + } + + // 축제 목록 조회 + async searchFestival(params) { + return this.request('/searchFestival', { + listYN: 'Y', + arrange: 'A', + ...params, + }); + } + + // 숙박시설 조회 + async searchStay(params) { + return this.request('/searchStay', { + listYN: 'Y', + arrange: 'A', + ...params, + }); + } + + // 위치기반 조회 + async locationBasedList(mapX, mapY, radius = 10000) { + return this.request('/locationBasedList', { + mapX, + mapY, + radius, // 미터 단위 + listYN: 'Y', + arrange: 'E', // 거리순 + }); + } + + // 상세 정보 조회 + async detailCommon(contentId) { + return this.request('/detailCommon', { + contentId, + defaultYN: 'Y', + firstImageYN: 'Y', + addrinfoYN: 'Y', + mapinfoYN: 'Y', + overviewYN: 'Y', + }); + } + + // 소개 정보 조회 (축제 상세) + async detailIntro(contentId, contentTypeId = '15') { + return this.request('/detailIntro', { + contentId, + contentTypeId, + }); + } + + // 지역코드 조회 + async getAreaCodes() { + return this.request('/areaCode', { numOfRows: 50 }); + } + + // 시군구코드 조회 + async getSigunguCodes(areaCode) { + return this.request('/areaCode', { + areaCode, + numOfRows: 100, + }); + } +} + +module.exports = TourApiClient; +``` + +#### 5.2.2 축제 서비스 + +```javascript +// server/services/festivalService.js + +const TourApiClient = require('./tourApiClient'); +const db = require('../db'); + +class FestivalService { + constructor() { + this.tourApi = new TourApiClient(process.env.TOURAPI_KEY); + } + + // 축제 데이터 동기화 + async syncFestivals(options = {}) { + const { + areaCode = null, + startDate = this.getTodayString(), + endDate = this.getDateAfterMonths(6), + } = options; + + console.log(`축제 동기화 시작: ${startDate} ~ ${endDate}`); + + let pageNo = 1; + let totalSynced = 0; + + while (true) { + const params = { + numOfRows: 100, + pageNo, + eventStartDate: startDate, + eventEndDate: endDate, + }; + + if (areaCode) { + params.areaCode = areaCode; + } + + const result = await this.tourApi.searchFestival(params); + + if (!result.items.length) break; + + for (const item of result.items) { + await this.upsertFestival(item); + totalSynced++; + } + + if (result.items.length < 100) break; + pageNo++; + + // API 호출 제한 대응 + await this.sleep(100); + } + + console.log(`축제 동기화 완료: ${totalSynced}건`); + return totalSynced; + } + + // 축제 데이터 저장/업데이트 + async upsertFestival(item) { + const exists = await 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, + mapx: parseFloat(item.mapx) || null, + 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 db.run(` + UPDATE festivals SET + title = ?, addr1 = ?, addr2 = ?, area_code = ?, sigungu_code = ?, + 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.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 + ]); + } else { + await db.run(` + INSERT INTO festivals ( + content_id, content_type_id, title, addr1, addr2, area_code, sigungu_code, + 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.mapx, data.mapy, data.event_start_date, + data.event_end_date, data.first_image, data.first_image2, data.tel, + data.last_synced_at + ]); + } + } + + // 축제 상세 정보 가져오기 + async fetchFestivalDetail(contentId) { + const [common, intro] = await Promise.all([ + this.tourApi.detailCommon(contentId), + this.tourApi.detailIntro(contentId, '15'), + ]); + + const commonItem = common.items[0] || {}; + const introItem = intro.items[0] || {}; + + await 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 = ? + `, [ + commonItem.overview, + commonItem.homepage, + introItem.eventplace, + introItem.placeinfo, + introItem.playtime, + introItem.program, + introItem.usetimefestival, + introItem.agelimit, + introItem.sponsor1, + introItem.sponsor1tel, + introItem.sponsor2, + introItem.sponsor2tel, + introItem.subevent, + introItem.bookingplace, + contentId + ]); + } + + // 활성 축제 조회 + async getActiveFestivals(options = {}) { + const { + areaCode = null, + limit = 20, + offset = 0, + } = options; + + const today = this.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 db.all(query, params); + } + + // 진행중인 축제 조회 + async getOngoingFestivals(areaCode = null) { + const today = this.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'; + + return db.all(query, params); + } + + // 유틸리티 함수 + getTodayString() { + return new Date().toISOString().slice(0, 10).replace(/-/g, ''); + } + + getDateAfterMonths(months) { + const date = new Date(); + date.setMonth(date.getMonth() + months); + return date.toISOString().slice(0, 10).replace(/-/g, ''); + } + + sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + +module.exports = new FestivalService(); +``` + +#### 5.2.3 매칭 서비스 + +```javascript +// server/services/matchingService.js + +const db = require('../db'); + +class MatchingService { + // 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 R * c; + } + + toRad(deg) { + return deg * (Math.PI / 180); + } + + // 거리 → 예상 이동시간 (분) 변환 + estimateTravelTime(distanceKm) { + // 평균 시속 40km 가정 (국도/지방도 기준) + const hours = distanceKm / 40; + return Math.round(hours * 60); + } + + // 매칭 점수 계산 (0-100) + calculateMatchScore(distanceKm, isSameRegion) { + let score = 100; + + // 거리에 따른 감점 + if (distanceKm <= 10) score -= 0; + else if (distanceKm <= 20) score -= 10; + else if (distanceKm <= 30) score -= 20; + else if (distanceKm <= 50) score -= 35; + else if (distanceKm <= 80) score -= 50; + else score -= 70; + + // 같은 지역이면 보너스 + if (isSameRegion) score += 10; + + return Math.max(0, Math.min(100, score)); + } + + // 펜션과 축제 매칭 + async matchPensionToFestivals(pensionId, pensionType = 'public') { + // 펜션 정보 조회 + const pensionTable = pensionType === 'public' ? 'public_pensions' : 'pension_profiles'; + const pension = await db.get(`SELECT * FROM ${pensionTable} WHERE id = ?`, [pensionId]); + + if (!pension || !pension.mapx || !pension.mapy) { + console.log(`펜션 ID ${pensionId}: 좌표 정보 없음`); + return []; + } + + // 활성 축제 조회 (6개월 이내) + const today = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const festivals = await db.all(` + SELECT * FROM festivals + WHERE is_active = 1 + AND event_end_date >= ? + AND mapx IS NOT NULL + AND mapy IS NOT NULL + `, [today]); + + const matches = []; + + for (const festival of festivals) { + const distance = this.calculateDistance( + pension.mapy, pension.mapx, // 펜션 위도, 경도 + festival.mapy, festival.mapx // 축제 위도, 경도 + ); + + // 80km 이내만 매칭 + if (distance > 80) continue; + + const isSameRegion = pension.area_code === festival.area_code; + const matchType = isSameRegion ? 'same_region' : 'nearby'; + const travelTime = this.estimateTravelTime(distance); + const matchScore = this.calculateMatchScore(distance, isSameRegion); + + // 매칭 저장 + await db.run(` + INSERT OR REPLACE INTO pension_festival_matches + (pension_id, pension_type, festival_id, distance_km, travel_time_min, match_type, match_score) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, [pensionId, pensionType, festival.id, distance.toFixed(2), travelTime, matchType, matchScore]); + + matches.push({ + festival, + distance: distance.toFixed(2), + travelTime, + matchType, + matchScore, + }); + } + + return matches.sort((a, b) => a.distance - b.distance); + } + + // 축제 주변 펜션 조회 + async getPensionsNearFestival(festivalId, options = {}) { + const { maxDistance = 50, limit = 20 } = options; + + const festival = await db.get('SELECT * FROM festivals WHERE id = ?', [festivalId]); + + if (!festival || !festival.mapx || !festival.mapy) { + return []; + } + + // 이미 계산된 매칭이 있으면 사용 + const cached = await db.all(` + SELECT pp.*, m.distance_km, m.travel_time_min, m.match_type, m.match_score + FROM pension_festival_matches m + JOIN public_pensions pp ON m.pension_id = pp.id AND m.pension_type = 'public' + WHERE m.festival_id = ? AND m.distance_km <= ? + ORDER BY m.match_score DESC, m.distance_km ASC + LIMIT ? + `, [festivalId, maxDistance, limit]); + + if (cached.length > 0) { + return cached; + } + + // 캐시 없으면 실시간 계산 + const pensions = await db.all(` + SELECT * FROM public_pensions + WHERE mapx IS NOT NULL AND mapy IS NOT NULL + AND business_status = '영업중' + `); + + const results = []; + + for (const pension of pensions) { + const distance = this.calculateDistance( + festival.mapy, festival.mapx, + pension.mapy, pension.mapx + ); + + if (distance <= maxDistance) { + results.push({ + ...pension, + distance_km: distance.toFixed(2), + travel_time_min: this.estimateTravelTime(distance), + }); + } + } + + return results + .sort((a, b) => a.distance_km - b.distance_km) + .slice(0, limit); + } + + // 전체 매칭 갱신 (배치) + async refreshAllMatches() { + console.log('전체 매칭 갱신 시작...'); + + const pensions = await db.all(` + SELECT id FROM public_pensions + WHERE mapx IS NOT NULL AND mapy IS NOT NULL + `); + + let processed = 0; + for (const pension of pensions) { + await this.matchPensionToFestivals(pension.id, 'public'); + processed++; + + if (processed % 100 === 0) { + console.log(`매칭 진행: ${processed}/${pensions.length}`); + } + } + + console.log(`전체 매칭 갱신 완료: ${processed}건`); + } +} + +module.exports = new MatchingService(); +``` + +#### 5.2.4 Geocoding 서비스 + +```javascript +// server/services/geocodingService.js + +const axios = require('axios'); + +class GeocodingService { + constructor() { + this.kakaoClient = axios.create({ + baseURL: 'https://dapi.kakao.com/v2/local', + headers: { + Authorization: `KakaoAK ${process.env.KAKAO_REST_KEY}`, + }, + }); + } + + // 주소 → 좌표 변환 + async geocode(address) { + try { + const response = await this.kakaoClient.get('/search/address.json', { + params: { query: address }, + }); + + const documents = response.data.documents; + if (!documents.length) { + return null; + } + + const result = documents[0]; + return { + mapx: parseFloat(result.x), // 경도 + mapy: parseFloat(result.y), // 위도 + address: result.address_name, + roadAddress: result.road_address?.address_name, + }; + } catch (error) { + console.error('Geocoding 실패:', address, error.message); + return null; + } + } + + // 좌표 → 주소 변환 (역지오코딩) + async reverseGeocode(mapx, mapy) { + try { + const response = await this.kakaoClient.get('/geo/coord2address.json', { + params: { x: mapx, y: mapy }, + }); + + const documents = response.data.documents; + if (!documents.length) { + return null; + } + + const result = documents[0]; + return { + address: result.address?.address_name, + roadAddress: result.road_address?.address_name, + region1: result.address?.region_1depth_name, // 시도 + region2: result.address?.region_2depth_name, // 시군구 + region3: result.address?.region_3depth_name, // 읍면동 + }; + } catch (error) { + console.error('역지오코딩 실패:', mapx, mapy, error.message); + return null; + } + } + + // 좌표 → 지역코드 매핑 + async getAreaCodeFromCoords(mapx, mapy) { + const region = await this.reverseGeocode(mapx, mapy); + if (!region) return null; + + // 시도명 → 지역코드 매핑 + 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 { + areaCode: areaCodeMap[region.region1] || null, + sido: region.region1, + sigungu: region.region2, + }; + } + + // 주소에서 시도/시군구 파싱 + parseAddress(address) { + if (!address) return { sido: null, sigungu: null }; + + const sidoPatterns = [ + /^(서울특별시|서울)/, + /^(부산광역시|부산)/, + /^(대구광역시|대구)/, + /^(인천광역시|인천)/, + /^(광주광역시|광주)/, + /^(대전광역시|대전)/, + /^(울산광역시|울산)/, + /^(세종특별자치시|세종)/, + /^(경기도|경기)/, + /^(강원특별자치도|강원도|강원)/, + /^(충청북도|충북)/, + /^(충청남도|충남)/, + /^(전북특별자치도|전라북도|전북)/, + /^(전라남도|전남)/, + /^(경상북도|경북)/, + /^(경상남도|경남)/, + /^(제주특별자치도|제주)/, + ]; + + let sido = null; + let sigungu = null; + + for (const pattern of sidoPatterns) { + const match = address.match(pattern); + if (match) { + sido = match[1]; + + // 시군구 추출 + const remaining = address.slice(match[0].length).trim(); + const sigunguMatch = remaining.match(/^([가-힣]+[시군구])/); + if (sigunguMatch) { + sigungu = sigunguMatch[1]; + } + break; + } + } + + return { sido, sigungu }; + } +} + +module.exports = new GeocodingService(); +``` + +--- + +## 6. API 엔드포인트 + +### 6.1 축제 API + +```javascript +// server/routes/festivalRoutes.js + +const express = require('express'); +const router = express.Router(); +const festivalService = require('../services/festivalService'); +const matchingService = require('../services/matchingService'); + +// 축제 목록 조회 +// GET /api/festivals?areaCode=31&status=ongoing&page=1&limit=20 +router.get('/', async (req, res) => { + try { + const { areaCode, status, page = 1, limit = 20 } = req.query; + const offset = (page - 1) * limit; + + let festivals; + if (status === 'ongoing') { + festivals = await festivalService.getOngoingFestivals(areaCode); + } else { + festivals = await festivalService.getActiveFestivals({ + areaCode, + limit: parseInt(limit), + offset, + }); + } + + res.json({ success: true, data: festivals }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// 축제 상세 조회 +// GET /api/festivals/:id +router.get('/:id', async (req, res) => { + try { + const festival = await db.get('SELECT * FROM festivals WHERE id = ?', [req.params.id]); + + if (!festival) { + return res.status(404).json({ success: false, error: '축제를 찾을 수 없습니다' }); + } + + // 상세 정보 없으면 가져오기 + if (!festival.overview) { + await festivalService.fetchFestivalDetail(festival.content_id); + festival = await db.get('SELECT * FROM festivals WHERE id = ?', [req.params.id]); + } + + res.json({ success: true, data: festival }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// 축제 주변 펜션 조회 +// GET /api/festivals/:id/pensions?maxDistance=50&limit=20 +router.get('/:id/pensions', async (req, res) => { + try { + const { maxDistance = 50, limit = 20 } = req.query; + + const pensions = await matchingService.getPensionsNearFestival( + req.params.id, + { maxDistance: parseFloat(maxDistance), limit: parseInt(limit) } + ); + + res.json({ success: true, data: pensions }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// 월별 축제 조회 +// GET /api/festivals/calendar/:year/:month +router.get('/calendar/:year/:month', async (req, res) => { + try { + const { year, month } = req.params; + const startDate = `${year}${month.padStart(2, '0')}01`; + const endDate = `${year}${month.padStart(2, '0')}31`; + + const festivals = await 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]); + + res.json({ success: true, data: festivals }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// 지역별 축제 통계 +// GET /api/festivals/stats/by-region +router.get('/stats/by-region', async (req, res) => { + try { + const stats = await db.all(` + SELECT + area_code, + COUNT(*) as total, + SUM(CASE WHEN event_start_date <= date('now') AND event_end_date >= date('now') THEN 1 ELSE 0 END) as ongoing + FROM festivals + WHERE is_active = 1 + GROUP BY area_code + `); + + res.json({ success: true, data: stats }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// 관리자: 축제 동기화 +// POST /api/admin/festivals/sync +router.post('/admin/sync', requireAdmin, async (req, res) => { + try { + const count = await festivalService.syncFestivals(req.body); + res.json({ success: true, message: `${count}건 동기화 완료` }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; +``` + +### 6.2 공개 펜션 API + +```javascript +// server/routes/publicPensionRoutes.js + +const express = require('express'); +const router = express.Router(); +const matchingService = require('../services/matchingService'); + +// 지역별 펜션 목록 +// GET /api/public/pensions?sido=경기&sigungu=가평군&page=1&limit=20 +router.get('/', async (req, res) => { + try { + const { sido, sigungu, page = 1, limit = 20 } = req.query; + const offset = (page - 1) * limit; + + let query = ` + SELECT * FROM public_pensions + WHERE business_status = '영업중' + `; + const params = []; + + if (sido) { + query += ' AND sido LIKE ?'; + params.push(`%${sido}%`); + } + + if (sigungu) { + query += ' AND sigungu LIKE ?'; + params.push(`%${sigungu}%`); + } + + query += ' ORDER BY view_count DESC, name ASC LIMIT ? OFFSET ?'; + params.push(parseInt(limit), offset); + + const pensions = await db.all(query, params); + + // 총 개수 + let countQuery = `SELECT COUNT(*) as total FROM public_pensions WHERE business_status = '영업중'`; + if (sido) countQuery += ` AND sido LIKE '%${sido}%'`; + if (sigungu) countQuery += ` AND sigungu LIKE '%${sigungu}%'`; + const { total } = await db.get(countQuery); + + res.json({ + success: true, + data: pensions, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total, + totalPages: Math.ceil(total / limit) + } + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// 펜션 상세 조회 +// GET /api/public/pensions/:id +router.get('/:id', async (req, res) => { + try { + const pension = await db.get( + 'SELECT * FROM public_pensions WHERE id = ?', + [req.params.id] + ); + + if (!pension) { + return res.status(404).json({ success: false, error: '펜션을 찾을 수 없습니다' }); + } + + // 조회수 증가 + await db.run( + 'UPDATE public_pensions SET view_count = view_count + 1 WHERE id = ?', + [req.params.id] + ); + + res.json({ success: true, data: pension }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// 펜션 검색 +// GET /api/public/pensions/search?q=가평펜션 +router.get('/search', async (req, res) => { + try { + const { q, limit = 20 } = req.query; + + if (!q || q.length < 2) { + return res.status(400).json({ success: false, error: '검색어는 2자 이상 입력하세요' }); + } + + const pensions = await db.all(` + SELECT * FROM public_pensions + WHERE business_status = '영업중' + AND (name LIKE ? OR address LIKE ?) + ORDER BY view_count DESC + LIMIT ? + `, [`%${q}%`, `%${q}%`, parseInt(limit)]); + + res.json({ success: true, data: pensions }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// 근처 펜션 조회 (좌표 기반) +// GET /api/public/pensions/nearby?lat=37.5&lng=127.5&radius=30 +router.get('/nearby', async (req, res) => { + try { + const { lat, lng, radius = 30, limit = 20 } = req.query; + + if (!lat || !lng) { + return res.status(400).json({ success: false, error: '좌표를 입력하세요' }); + } + + // 간단한 경계 박스 필터링 (정확한 거리 계산 전 1차 필터) + const latRange = parseFloat(radius) / 111; // 위도 1도 ≈ 111km + const lngRange = parseFloat(radius) / 88; // 경도 1도 ≈ 88km (한국 기준) + + const pensions = await db.all(` + SELECT * FROM public_pensions + WHERE business_status = '영업중' + AND mapy BETWEEN ? AND ? + AND mapx BETWEEN ? AND ? + `, [ + parseFloat(lat) - latRange, + parseFloat(lat) + latRange, + parseFloat(lng) - lngRange, + parseFloat(lng) + lngRange, + ]); + + // 정확한 거리 계산 및 필터링 + const results = pensions + .map(pension => ({ + ...pension, + distance: matchingService.calculateDistance( + parseFloat(lat), parseFloat(lng), + pension.mapy, pension.mapx + ) + })) + .filter(p => p.distance <= parseFloat(radius)) + .sort((a, b) => a.distance - b.distance) + .slice(0, parseInt(limit)); + + res.json({ success: true, data: results }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// 펜션 주변 축제 조회 +// GET /api/public/pensions/:id/festivals +router.get('/:id/festivals', async (req, res) => { + try { + const matches = await db.all(` + SELECT f.*, m.distance_km, m.travel_time_min, m.match_type, m.match_score + FROM pension_festival_matches m + JOIN festivals f ON m.festival_id = f.id + WHERE m.pension_id = ? AND m.pension_type = 'public' + AND f.event_end_date >= date('now') + ORDER BY m.match_score DESC, f.event_start_date ASC + LIMIT 10 + `, [req.params.id]); + + res.json({ success: true, data: matches }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// 지역별 통계 +// GET /api/public/pensions/stats/by-region +router.get('/stats/by-region', async (req, res) => { + try { + const stats = await db.all(` + SELECT + sido, + COUNT(*) as total, + SUM(CASE WHEN is_claimed = 1 THEN 1 ELSE 0 END) as claimed + FROM public_pensions + WHERE business_status = '영업중' + GROUP BY sido + ORDER BY total DESC + `); + + res.json({ success: true, data: stats }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; +``` + +### 6.3 펜션 클레임 API + +```javascript +// server/routes/claimRoutes.js + +// 내 펜션 찾기 (이름/주소로 검색) +// GET /api/claim/search?q=OO펜션 +router.get('/search', authenticateToken, async (req, res) => { + const { q } = req.query; + + const pensions = await db.all(` + SELECT id, name, address, thumbnail, is_claimed, claimed_by + FROM public_pensions + WHERE business_status = '영업중' + AND (name LIKE ? OR address LIKE ?) + LIMIT 20 + `, [`%${q}%`, `%${q}%`]); + + res.json({ success: true, data: pensions }); +}); + +// 펜션 클레임 신청 +// POST /api/claim +router.post('/', authenticateToken, async (req, res) => { + const { public_pension_id, verification_method, verification_data } = req.body; + const userId = req.user.id; + + // 이미 클레임된 펜션인지 확인 + const pension = await db.get( + 'SELECT * FROM public_pensions WHERE id = ?', + [public_pension_id] + ); + + if (!pension) { + return res.status(404).json({ success: false, error: '펜션을 찾을 수 없습니다' }); + } + + if (pension.is_claimed) { + return res.status(400).json({ success: false, error: '이미 인증된 펜션입니다' }); + } + + // 중복 신청 확인 + const existingClaim = await db.get(` + SELECT * FROM pension_claims + WHERE public_pension_id = ? AND user_id = ? AND status = 'pending' + `, [public_pension_id, userId]); + + if (existingClaim) { + return res.status(400).json({ success: false, error: '이미 신청 중입니다' }); + } + + // 클레임 신청 저장 + await db.run(` + INSERT INTO pension_claims (public_pension_id, user_id, verification_method, verification_data) + VALUES (?, ?, ?, ?) + `, [public_pension_id, userId, verification_method, JSON.stringify(verification_data)]); + + res.json({ success: true, message: '인증 신청이 완료되었습니다' }); +}); + +// 클레임 승인 (관리자) +// POST /api/admin/claims/:id/approve +router.post('/admin/claims/:id/approve', requireAdmin, async (req, res) => { + const claim = await db.get('SELECT * FROM pension_claims WHERE id = ?', [req.params.id]); + + if (!claim) { + return res.status(404).json({ success: false, error: '신청을 찾을 수 없습니다' }); + } + + // 클레임 승인 + await db.run(` + UPDATE pension_claims + SET status = 'approved', reviewed_by = ?, reviewed_at = CURRENT_TIMESTAMP + WHERE id = ? + `, [req.user.id, req.params.id]); + + // 펜션 소유권 설정 + await db.run(` + UPDATE public_pensions + SET is_claimed = 1, claimed_by = ?, claimed_at = CURRENT_TIMESTAMP + WHERE id = ? + `, [claim.user_id, claim.public_pension_id]); + + // 사용자 pension_profiles와 연결 + await db.run(` + UPDATE pension_profiles + SET public_pension_id = ? + WHERE user_id = ? AND is_default = 1 + `, [claim.public_pension_id, claim.user_id]); + + res.json({ success: true, message: '승인 완료' }); +}); +``` + +### 6.4 API 엔드포인트 요약 + +| 메서드 | 엔드포인트 | 설명 | 인증 | +|-------|-----------|------|------| +| **축제** | +| GET | `/api/festivals` | 축제 목록 | - | +| GET | `/api/festivals/:id` | 축제 상세 | - | +| GET | `/api/festivals/:id/pensions` | 축제 주변 펜션 | - | +| GET | `/api/festivals/calendar/:year/:month` | 월별 축제 | - | +| GET | `/api/festivals/stats/by-region` | 지역별 통계 | - | +| POST | `/api/admin/festivals/sync` | 축제 동기화 | 관리자 | +| **펜션** | +| GET | `/api/public/pensions` | 펜션 목록 | - | +| GET | `/api/public/pensions/:id` | 펜션 상세 | - | +| GET | `/api/public/pensions/search` | 펜션 검색 | - | +| GET | `/api/public/pensions/nearby` | 근처 펜션 | - | +| GET | `/api/public/pensions/:id/festivals` | 펜션 주변 축제 | - | +| GET | `/api/public/pensions/stats/by-region` | 지역별 통계 | - | +| **클레임** | +| GET | `/api/claim/search` | 내 펜션 찾기 | 로그인 | +| POST | `/api/claim` | 클레임 신청 | 로그인 | +| POST | `/api/admin/claims/:id/approve` | 클레임 승인 | 관리자 | + +--- + +## 7. 프론트엔드 설계 + +### 7.1 새로운 페이지 구조 + +``` +src/pages/ +├── FestivalsPage.tsx # 축제 검색/목록 +├── FestivalDetailPage.tsx # 축제 상세 + 주변 펜션 +├── RegionalPensionsPage.tsx # 지역별 펜션 목록 +├── PensionClaimPage.tsx # 펜션 인증 신청 +└── LandingPage.tsx # (수정) 축제/펜션 섹션 추가 + +components/ +├── festival/ +│ ├── FestivalCard.tsx # 축제 카드 +│ ├── FestivalCalendar.tsx # 월별 축제 캘린더 +│ ├── FestivalFilter.tsx # 지역/기간 필터 +│ └── FestivalMap.tsx # 축제 지도 표시 +├── pension/ +│ ├── PublicPensionCard.tsx # 공개 펜션 카드 +│ ├── PensionSearchBar.tsx # 펜션 검색 +│ ├── RegionalPensionGrid.tsx # 지역별 펜션 그리드 +│ └── DistanceBadge.tsx # "차량 30분" 배지 +├── match/ +│ ├── FestivalPensionMatch.tsx # 축제-펜션 매칭 표시 +│ └── NearbyFestivals.tsx # 펜션 상세 내 축제 목록 +└── landing/ + ├── FestivalCarousel.tsx # 랜딩 축제 캐러셀 + └── RegionalSection.tsx # 랜딩 지역별 섹션 +``` + +### 7.2 랜딩페이지 수정 + +```tsx +// src/pages/LandingPage.tsx - 추가 섹션 + +// 실시간 축제 섹션 +
+

🎪 이번 달 전국 축제

+

축제도 보고, 펜션에서 쉬어가세요!

+ + + +
+ 모든 축제 보기 → +
+
+ +// 지역별 펜션 섹션 +
+

🏡 전국 {totalPensionCount.toLocaleString()}개 펜션

+ +
+ {regionStats.map(region => ( + navigate(`/pensions?sido=${region.sido}`)} + /> + ))} +
+ +
+

우리 펜션도 등록하고 싶으신가요?

+

전국 축제와 연계한 마케팅 영상을 무료로 만들어보세요!

+ + 무료로 시작하기 + +
+
+``` + +### 7.3 축제 상세 페이지 + +```tsx +// src/pages/FestivalDetailPage.tsx + +const FestivalDetailPage = () => { + const { id } = useParams(); + const [festival, setFestival] = useState(null); + const [nearbyPensions, setNearbyPensions] = useState([]); + const [activeTab, setActiveTab] = useState('info'); // info | pensions | map + + useEffect(() => { + fetchFestival(id); + fetchNearbyPensions(id); + }, [id]); + + return ( +
+ {/* 헤더 */} +
+
+

{festival.title}

+
+ + 📅 {formatDate(festival.event_start_date)} ~ {formatDate(festival.event_end_date)} + + 📍 {festival.addr1} +
+
+
+ + {/* 탭 네비게이션 */} +
+ + + +
+ + {/* 탭 콘텐츠 */} + {activeTab === 'info' && ( +
+
+

축제 소개

+

{festival.overview}

+
+ +
+
+ + {festival.place} +
+
+ + {festival.use_fee || '무료'} +
+
+ + {festival.tel} +
+ {festival.homepage && ( +
+ + 바로가기 +
+ )} +
+
+ )} + + {activeTab === 'pensions' && ( +
+

축제장 근처 펜션

+

축제 후 편하게 쉴 수 있는 주변 숙소입니다

+ +
+ {nearbyPensions.map(pension => ( + + ))} +
+
+ )} + + {activeTab === 'map' && ( +
+ +
+ )} +
+ ); +}; +``` + +### 7.4 컴포넌트 설계 + +```tsx +// components/festival/FestivalCard.tsx +interface FestivalCardProps { + festival: Festival; + variant?: 'default' | 'compact' | 'featured'; +} + +const FestivalCard = ({ festival, variant = 'default' }: FestivalCardProps) => { + const isOngoing = isFestivalOngoing(festival); + + return ( +
+
+ {festival.title} + {isOngoing && 진행중} +
+
+

{festival.title}

+

+ {formatDate(festival.event_start_date)} ~ {formatDate(festival.event_end_date)} +

+

{festival.addr1}

+
+
+ ); +}; + +// components/pension/DistanceBadge.tsx +interface DistanceBadgeProps { + distanceKm: number; + travelTimeMin: number; +} + +const DistanceBadge = ({ distanceKm, travelTimeMin }: DistanceBadgeProps) => { + let variant = 'far'; + if (distanceKm <= 15) variant = 'very-close'; + else if (distanceKm <= 30) variant = 'close'; + else if (distanceKm <= 50) variant = 'medium'; + + return ( + + 🚗 차량 {travelTimeMin}분 ({distanceKm}km) + + ); +}; + +// components/match/FestivalPensionMatch.tsx +const FestivalPensionMatch = ({ festival, pension, distance }) => { + return ( +
+
+ 🎪 축제 연계 숙소 + +
+
+
+

{festival.title}

+

{formatDateRange(festival.event_start_date, festival.event_end_date)}

+
+
+

{pension.name}

+

{pension.address}

+
+
+
+

축제도 즐기고, 펜션에서 편하게 쉬어가세요!

+
+
+ ); +}; +``` + +--- + +## 8. AI 콘텐츠 융합 + +### 8.1 Gemini 프롬프트 확장 + +```javascript +// server/geminiBackendService.js + +async function generateContentWithFestival(pensionInfo, nearbyFestivals) { + // 축제 정보 컨텍스트 생성 + const festivalContext = nearbyFestivals.length > 0 ? ` + +🎪 인근에서 열리는 축제 정보: +${nearbyFestivals.slice(0, 3).map(f => ` +- 축제명: ${f.festival.title} +- 기간: ${formatDate(f.festival.event_start_date)} ~ ${formatDate(f.festival.event_end_date)} +- 장소: ${f.festival.addr1} +- 거리: 차량 약 ${f.travel_time_min}분 (${f.distance_km}km) +`).join('\n')} + +위 축제와 연계하여 "축제도 즐기고 펜션에서 힐링"하는 컨셉을 자연스럽게 녹여주세요. +` : ''; + + // 확장된 프롬프트 + const prompt = ` +당신은 감성적인 펜션 홍보 콘텐츠를 만드는 전문 카피라이터입니다. + +📍 펜션 정보: +- 이름: ${pensionInfo.name} +- 위치: ${pensionInfo.address} +- 특징: ${pensionInfo.features?.join(', ') || '자연 속 힐링'} +- 타겟: ${pensionInfo.targetCustomers?.join(', ') || '가족, 커플'} + +${festivalContext} + +위 정보를 바탕으로 다음을 생성해주세요: + +1. 홍보 문구 (30자 이내) +2. 상세 설명 (100자 이내) +3. 해시태그 5개 + +JSON 형식으로 응답해주세요. +`; + + return await gemini.generateContent(prompt); +} +``` + +### 8.2 로고송 가사에 축제 반영 + +```javascript +// 축제 연계 가사 생성 +async function generateLyricsWithFestival(pensionInfo, festival) { + const prompt = ` +🎵 펜션 로고송 가사 작성 + +펜션: ${pensionInfo.name} +위치: ${pensionInfo.address} + +🎪 인근 축제: ${festival.title} +축제 기간: ${festival.event_start_date} ~ ${festival.event_end_date} + +요청사항: +1. 펜션의 매력을 살리면서 +2. 인근 축제와 연계한 즐거움을 담아 +3. "축제 보러 왔다가 펜션에서 쉬어가요" 느낌의 +4. 밝고 경쾌한 로고송 가사를 작성해주세요 + +가사 형식: +- 1절: 펜션 소개 + 축제 언급 +- 후렴: 기억에 남는 펜션 이름 반복 +- 2절: 축제의 즐거움 + 펜션에서의 휴식 + +총 150자 내외로 작성해주세요. +`; + + return await gemini.generateContent(prompt); +} +``` + +### 8.3 영상 내 축제 정보 표시 + +```javascript +// 영상 렌더링 시 축제 정보 오버레이 +const videoConfig = { + // ... 기존 설정 + + festivalOverlay: nearbyFestival ? { + enabled: true, + position: 'bottom-right', + showAt: 45, // 45초 지점 + duration: 5, // 5초간 표시 + content: { + badge: '🎪 인근 축제', + title: nearbyFestival.title, + date: `${nearbyFestival.event_start_date} ~ ${nearbyFestival.event_end_date}`, + distance: `차량 ${nearbyFestival.travel_time_min}분`, + }, + } : null, +}; +``` + +--- + +## 9. 구현 로드맵 + +### Phase 1: 기반 구축 (1주) + +| 작업 | 상세 | 담당 | +|-----|------|------| +| API 키 발급 | TourAPI, 카카오 Local API | - | +| DB 스키마 생성 | 테이블 생성, 인덱스, 초기 데이터 | Backend | +| TourAPI 클라이언트 | 기본 API 연동 | Backend | +| Geocoding 서비스 | 주소 → 좌표 변환 | Backend | + +**산출물:** +- [ ] `server/services/tourApiClient.js` +- [ ] `server/services/geocodingService.js` +- [ ] DB 마이그레이션 스크립트 + +### Phase 2: 데이터 수집 (1주) + +| 작업 | 상세 | 담당 | +|-----|------|------| +| 축제 동기화 | TourAPI → festivals 테이블 | Backend | +| 펜션 데이터 수집 | 공공데이터 → public_pensions | Backend | +| 데이터 정제 | 중복 제거, 좌표 보정 | Backend | +| 정기 동기화 설정 | Cron job 설정 | DevOps | + +**산출물:** +- [ ] `server/services/festivalService.js` +- [ ] `server/services/pensionDataService.js` +- [ ] `server/scripts/syncFestivals.js` +- [ ] `server/scripts/syncPensions.js` + +### Phase 3: 매칭 시스템 (1주) + +| 작업 | 상세 | 담당 | +|-----|------|------| +| 거리 계산 서비스 | Haversine 공식 구현 | Backend | +| 매칭 로직 구현 | 펜션-축제 자동 매칭 | Backend | +| 매칭 API | 엔드포인트 구현 | Backend | +| 배치 매칭 | 전체 데이터 매칭 실행 | Backend | + +**산출물:** +- [ ] `server/services/matchingService.js` +- [ ] `server/routes/festivalRoutes.js` +- [ ] `server/routes/publicPensionRoutes.js` + +### Phase 4: 프론트엔드 - 기본 (1주) + +| 작업 | 상세 | 담당 | +|-----|------|------| +| 축제 목록 페이지 | 검색, 필터, 목록 | Frontend | +| 축제 상세 페이지 | 정보, 주변 펜션 | Frontend | +| 펜션 목록 페이지 | 지역별 필터 | Frontend | +| 공통 컴포넌트 | 카드, 배지, 필터 | Frontend | + +**산출물:** +- [ ] `src/pages/FestivalsPage.tsx` +- [ ] `src/pages/FestivalDetailPage.tsx` +- [ ] `src/pages/RegionalPensionsPage.tsx` +- [ ] `components/festival/*` +- [ ] `components/pension/*` + +### Phase 5: 프론트엔드 - 랜딩페이지 (3일) + +| 작업 | 상세 | 담당 | +|-----|------|------| +| 축제 섹션 추가 | 캐러셀, 진행중 축제 | Frontend | +| 지역별 펜션 섹션 | 지역 그리드, 통계 | Frontend | +| 가입 유도 CTA | 펜션 주인 타겟 | Frontend | + +**산출물:** +- [ ] `components/landing/FestivalCarousel.tsx` +- [ ] `components/landing/RegionalSection.tsx` +- [ ] LandingPage.tsx 수정 + +### Phase 6: 펜션 클레임 시스템 (3일) + +| 작업 | 상세 | 담당 | +|-----|------|------| +| 클레임 API | 신청, 승인 API | Backend | +| 클레임 페이지 | 펜션 검색, 인증 신청 | Frontend | +| 관리자 페이지 | 클레임 승인 관리 | Frontend | + +**산출물:** +- [ ] `server/routes/claimRoutes.js` +- [ ] `src/pages/PensionClaimPage.tsx` +- [ ] AdminDashboard 클레임 관리 탭 + +### Phase 7: AI 콘텐츠 융합 (3일) + +| 작업 | 상세 | 담당 | +|-----|------|------| +| Gemini 프롬프트 확장 | 축제 정보 포함 | Backend | +| 로고송 연동 | 축제 반영 가사 | Backend | +| 영상 오버레이 | 축제 정보 표시 | Backend | + +**산출물:** +- [ ] geminiBackendService.js 수정 +- [ ] 영상 템플릿 수정 + +### Phase 8: 테스트 및 최적화 (3일) + +| 작업 | 상세 | 담당 | +|-----|------|------| +| 통합 테스트 | E2E 테스트 | QA | +| 성능 최적화 | DB 쿼리, 캐싱 | Backend | +| 모바일 대응 | 반응형 점검 | Frontend | +| 배포 | 프로덕션 배포 | DevOps | + +--- + +## 10. 기술 스택 + +### 10.1 추가 기술 스택 + +| 구분 | 기술 | 용도 | +|-----|------|------| +| 외부 API | TourAPI 4.0 | 축제/숙박 데이터 | +| 외부 API | 카카오 Local API | Geocoding | +| 외부 API | 카카오맵 SDK | 지도 표시 | +| 스케줄러 | node-cron | 정기 동기화 | +| 캐싱 | node-cache | API 응답 캐싱 | + +### 10.2 환경 변수 추가 + +```env +# .env 추가 항목 + +# TourAPI (공공데이터포털) +TOURAPI_KEY=발급받은_서비스키 + +# 카카오 API +KAKAO_REST_KEY=카카오_REST_API_키 +KAKAO_JS_KEY=카카오_JavaScript_키 + +# 동기화 설정 +FESTIVAL_SYNC_ENABLED=true +FESTIVAL_SYNC_CRON=0 3 * * 0 # 매주 일요일 새벽 3시 +PENSION_SYNC_ENABLED=true +PENSION_SYNC_CRON=0 4 1 * * # 매월 1일 새벽 4시 +``` + +### 10.3 NPM 패키지 추가 + +```json +{ + "dependencies": { + "node-cron": "^3.0.3", + "node-cache": "^5.1.2", + "csv-parser": "^3.0.0" + } +} +``` + +--- + +## 부록 A: TourAPI 지역 코드표 + +| 코드 | 지역명 | 코드 | 지역명 | +|-----|-------|-----|-------| +| 1 | 서울특별시 | 31 | 경기도 | +| 2 | 인천광역시 | 32 | 강원특별자치도 | +| 3 | 대전광역시 | 33 | 충청북도 | +| 4 | 대구광역시 | 34 | 충청남도 | +| 5 | 광주광역시 | 35 | 경상북도 | +| 6 | 부산광역시 | 36 | 경상남도 | +| 7 | 울산광역시 | 37 | 전북특별자치도 | +| 8 | 세종특별자치시 | 38 | 전라남도 | +| | | 39 | 제주특별자치도 | + +--- + +## 부록 B: 참고 링크 + +- [TourAPI 공식 사이트](https://api.visitkorea.or.kr/) +- [공공데이터포털 - 국문관광정보서비스](https://www.data.go.kr/data/15101578/openapi.do) +- [전국관광펜션업소표준데이터](https://www.data.go.kr/data/15013105/standard.do) +- [카카오 개발자 - Local API](https://developers.kakao.com/docs/latest/ko/local/dev-guide) +- [카카오맵 SDK](https://apis.map.kakao.com/web/) + +--- + +**문서 끝** diff --git a/docs/images/architecture-distributed.svg b/docs/images/architecture-distributed.svg new file mode 100644 index 0000000..d7e9fd5 --- /dev/null +++ b/docs/images/architecture-distributed.svg @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CaStAD Distributed Architecture (Scale-Out) + + + + + Load Balancer + nginx / AWS ALB + + + + + + + + + API Server 1 + Express.js + + + API Server 2 + Express.js + + + API Server N + Express.js + + + + + + + + + + Redis + (Cache / Queue) + • Session Store + • Job Queue (Bull) + • Rate Limiting + + + + PostgreSQL + (Primary) + + Replica (Read) + High Availability + + + + S3 / MinIO + (Storage) + • Videos + • Images + • Assets + + + + + + + + + + Render Worker 1 + Puppeteer + FFmpeg + + + Render Worker 2 + Puppeteer + FFmpeg + + + Render Worker N + Puppeteer + FFmpeg + + + + + + + + + + + + + + Upload Worker 1 + YouTube + + + Upload Worker 2 + Instagram + + + Upload Worker N + TikTok + + + + + + + + + External APIs + Gemini • Suno + YouTube • IG • TikTok + + + + 50,000+ users + + + + + API Server + + Redis + + PostgreSQL + + S3/MinIO + + Render Worker + + Upload Worker + \ No newline at end of file diff --git a/docs/images/architecture-kubernetes.svg b/docs/images/architecture-kubernetes.svg new file mode 100644 index 0000000..a524634 --- /dev/null +++ b/docs/images/architecture-kubernetes.svg @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CaStAD Kubernetes Architecture + + + + + + + + Kubernetes Cluster + + + + Ingress Controller + nginx-ingress / Traefik + + + + + + + Deployments (Auto-Scaling with HPA) + + + + API Deployment + HPA: 2-10 pods + + + + + Pod + + + + Pod + + + + Pod + + + + ... + + + + + Render Worker + HPA: 3-20 pods (GPU nodes) + + + + + Pod + + + + Pod + + + + Pod + + + + ... + + + + + Upload Worker + HPA: 2-5 pods + + + + + Pod + + + + Pod + + + + ... + + + + + Scheduler (CronJob) + Weekly Auto-Generation + + + + Monitoring Stack + Prometheus + Grafana + + + + StatefulSets (Persistent Data) + + + + PostgreSQL (Primary + Replicas) + PersistentVolumeClaim + + + + Redis Cluster (3-node) + PersistentVolumeClaim + + + + External Services + + + + AWS S3 + + + Gemini API + + + Suno API + + + YouTube API + + + Instagram + + + TikTok + + + + + Ingress + + Deployment + + Pod + + StatefulSet + + External + + + HPA = Horizontal Pod Autoscaler + \ No newline at end of file diff --git a/docs/images/architecture-single-server.svg b/docs/images/architecture-single-server.svg new file mode 100644 index 0000000..07ab544 --- /dev/null +++ b/docs/images/architecture-single-server.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CaStAD Single Server Architecture + + + + + + Single Server + + + + Frontend + React + Vite (Port 5173) + + + + + + + + + + + + Backend + Express.js (Port 3001) + + + + Auth Service + + + Render Service + + + API Proxy + + + + + + + Database + SQLite (File-based) + + + + + + + File Storage + Local Filesystem (downloads/, temp/, uploads/) + + + + + + + Gemini API + AI Content + + + + Suno API + AI Music + + + + YouTube API + Video Upload + + + + + + + + + ~50 users + \ No newline at end of file diff --git a/nginx.castad.conf b/nginx.castad.conf new file mode 100644 index 0000000..0e1fad5 --- /dev/null +++ b/nginx.castad.conf @@ -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; + } +} diff --git a/package-lock.json b/package-lock.json index be0e1c6..1ce9235 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "bizvibe---ai-music-video-generator", - "version": "0.5.0", + "name": "castad-ai-marketing-video-platform", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "bizvibe---ai-music-video-generator", - "version": "0.5.0", + "name": "castad-ai-marketing-video-platform", + "version": "3.0.0", "dependencies": { "@ffmpeg/core": "0.12.6", "@ffmpeg/ffmpeg": "0.12.10", @@ -32,6 +32,7 @@ "dom": "^0.0.3", "lottie-react": "^2.4.1", "lucide-react": "^0.554.0", + "mammoth": "^1.11.0", "next-themes": "^0.4.6", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -3550,6 +3551,15 @@ "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": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3624,6 +3634,15 @@ "dev": true, "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": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3732,6 +3751,12 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -4099,6 +4124,12 @@ "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4165,6 +4196,12 @@ "dev": true, "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": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -4178,6 +4215,15 @@ "integrity": "sha512-Uzda1zIAXO8JG2fm6IbJcdzBrRaC5Q308HTIjCXCQHh7ZVACJOeQzYYvd99plJ2/HUpZQk9IxNI/Y+QrO6poIQ==", "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": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4592,6 +4638,18 @@ "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": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4663,6 +4721,12 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4736,6 +4800,18 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -4757,6 +4833,15 @@ "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": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4777,6 +4862,17 @@ "dev": true, "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": { "version": "2.4.1", "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" } }, + "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": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5008,12 +5128,33 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5255,6 +5396,12 @@ "dev": true, "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": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5424,6 +5571,27 @@ "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": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -5625,6 +5793,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5691,6 +5865,27 @@ "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": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -5988,6 +6183,12 @@ "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": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -6082,7 +6283,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "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": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 433a0d3..d841e8f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "castad-ai-marketing-video-platform", "private": true, - "version": "3.0.0", + "version": "3.7.0", "type": "module", "scripts": { "dev": "concurrently \"vite\" \"cd server && node index.js\"", @@ -35,6 +35,7 @@ "dom": "^0.0.3", "lottie-react": "^2.4.1", "lucide-react": "^0.554.0", + "mammoth": "^1.11.0", "next-themes": "^0.4.6", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..7776527 --- /dev/null +++ b/plan.md @@ -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. 완료 후 이메일/푸시 알림 (선택) diff --git a/public/images/architecture-diagram.svg b/public/images/architecture-diagram.svg new file mode 100644 index 0000000..2d47464 --- /dev/null +++ b/public/images/architecture-diagram.svg @@ -0,0 +1,274 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CaStAD System Architecture + + + + + Frontend (React 19 + Vite) + + + + Pages + • GeneratorPage (영상 생성) + • AdminDashboard (관리자) + • PensionsView (펜션 관리) + + Services + • geminiService (AI 콘텐츠) + • sunoService (음악 생성) + • naverService (크롤링) + • instagramService (크롤링) + + Contexts + • AuthContext + • LanguageContext + • ThemeContext + + Features + • 다국어 지원 (6개) + • JWT 인증 + • OAuth (Google/Naver) + • 실시간 프리뷰 + + Port: 5173 (dev) / 3000 (prod) + + + + + + Backend (Express.js) + + + + API Endpoints (107+) + • /api/auth/* (인증) + • /api/profile/* (펜션) + • /api/youtube/* (YouTube) + • /api/instagram/* (Instagram) + • /api/tiktok/* (TikTok) + • /api/gemini/* (AI) + • /api/admin/* (관리자) + + Core Services + • Puppeteer (렌더링) + • FFmpeg (영상 병합) + • JWT 인증 + • 쿠키 기반 크롤링 + • API 사용량 추적 + • 자동 생성 스케줄러 + + Port: 3001 + + + + + + Database (SQLite) + + + + Core Tables (30+) + • users (사용자/플랜/크레딧) + • pension_profiles (펜션) + • pension_images (이미지) + • history (영상 이력) + • youtube_connections + • instagram_connections + • tiktok_connections + + Data Tables + • festivals (축제) + • public_pensions + • api_usage_logs + • system_settings + • activity_logs + • credit_history + + File: server/database.sqlite + + + + + + External APIs + + + + + + Google + Gemini AI + Places API + OAuth 2.0 + + + + + Suno AI + Music Gen + V5 Model + Custom Mode + + + + + Naver + Map GraphQL + OAuth 2.0 + Cookie Auth + + + + + TourAPI + 축제 정보 + 펜션 정보 + 지역 코드 + + + + + + + Upload Platforms + + + + + + YouTube + OAuth 2.0 연동 + 플레이리스트 관리 + Analytics API + + + + + Instagram + Reels 업로드 + 자동 캡션/해시태그 + 주간 통계 + + + + + TikTok + OAuth 2.0 연동 + Direct Post API + 개인정보 설정 + + + + + + + Video Generation Pipeline + + + + + 1. 입력 + 펜션 정보 + 이미지 (최대 100장) + URL 크롤링 + (Naver/Google/Insta) + + + + + + + 2. AI 콘텐츠 생성 + Gemini: 광고 카피 + Gemini: TTS 음성 + Suno: 배경 음악 + DNA 분석 + + + + + + + 3. 영상 렌더링 + Puppeteer 캡처 + FFmpeg 병합 + H.264 + AAC + 9:16 / 16:9 / 1:1 + + + + + + + 4. 후처리 + SEO 최적화 + 다국어 제목/설명 + 해시태그 생성 + 썸네일 생성 + + + + + + + 5. 멀티 플랫폼 업로드 + YouTube (플레이리스트) + Instagram (Reels) + TikTok (Direct Post) + 자동/수동 선택 + + + + + + + + + + + + + + + + + + + + v3.6.0 - CaStAD Architecture + diff --git a/public/images/render-queue-architecture.svg b/public/images/render-queue-architecture.svg new file mode 100644 index 0000000..4075ef3 --- /dev/null +++ b/public/images/render-queue-architecture.svg @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CastAD v3.7.0 - Background Render Queue System + + + Asynchronous Video Rendering with Credit Management + + + + + CLIENT (React) + + + + ResultPlayer.tsx + + + handleServerDownload() + + + pollJobStatus() + + + LibraryView.tsx + + + YouTube Upload + + + Instagram Upload + + + + SERVER (Express.js) + + + + Render Queue API + + POST /api/render/start + → jobId, credit deduction + + GET /api/render/status/:id + → progress % + + GET /api/render/jobs + → job list + + * 계정당 동시 렌더링 1개 제한 + + + + Background Worker + + • processRenderQueue() + • MAX_CONCURRENT_RENDERS: 3 + • Puppeteer + FFmpeg + + + + Platform Upload APIs + + POST /api/youtube/my-upload + POST /api/instagram/upload + + + + DATABASE (SQLite) + + + + render_jobs + + id TEXT PRIMARY KEY + user_id, pension_id + status: pending|processing|completed|failed + progress: 0-100 + credits_charged, credits_refunded + + + credit_history + user_id, amount, type + description, balance_after + + + history + final_video_path + business_name, pension_id + + + + + 1. 렌더링 요청 + + + + 2. 작업 저장 + + + + 3. jobId 반환 + + + + 4. 진행률 + + + + 5. 폴링 + + + + Credit Management Flow + + + + 렌더링 요청 + 크레딧 체크 + + + 선차감 + -1 크레딧 + + + 성공 + 완료 + + + 실패 + 크레딧 환불 + + + + + + + + + 제한 사항 + 계정당: 1개 + 서버: 3개 + 동시 렌더링 + + + + v3.7.0 New Features + + • Background Render Queue - 페이지 이동/로그아웃해도 작업 계속 + • Credit Pre-deduction - 작업 시작 시 선차감, 실패 시 환불 + • Per-user Rate Limit - 계정당 동시 렌더링 1개 제한 + • Instagram Upload - ResultPlayer & LibraryView에서 업로드 + + \ No newline at end of file diff --git a/server/billingService.js b/server/billingService.js new file mode 100644 index 0000000..ab8d177 --- /dev/null +++ b/server/billingService.js @@ -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 +}; diff --git a/server/client_secret.json b/server/client_secret.json index f31187d..c524e3c 100644 --- a/server/client_secret.json +++ b/server/client_secret.json @@ -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"]}} \ No newline at end of file +{"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"]}} \ No newline at end of file diff --git a/server/client_secret.json_Zone.Identifier b/server/client_secret.json_Zone.Identifier deleted file mode 100644 index d6c1ec682968c796b9f5e9e080cc6f674b57c766..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2x { // 구독 만료일 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_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 분석 데이터 캐시 테이블 (펜션별) // ============================================ @@ -451,6 +581,34 @@ db.serialize(() => { FOREIGN KEY(related_request_id) REFERENCES credit_requests(id) ON DELETE SET NULL )`); + // ============================================ + // 렌더링 작업 큐 테이블 (백그라운드 처리) + // ============================================ + db.run(`CREATE TABLE IF NOT EXISTS render_jobs ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + pension_id INTEGER, + status TEXT DEFAULT 'pending', + progress INTEGER DEFAULT 0, + input_data TEXT, + output_path TEXT, + history_id INTEGER, + error_message TEXT, + credits_charged INTEGER DEFAULT 1, + credits_refunded INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + started_at DATETIME, + completed_at DATETIME, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(pension_id) REFERENCES pension_profiles(id) ON DELETE SET NULL, + FOREIGN KEY(history_id) REFERENCES history(id) ON DELETE SET NULL + )`); + + // render_jobs 인덱스 + db.run("CREATE INDEX IF NOT EXISTS idx_render_jobs_user ON render_jobs(user_id)"); + db.run("CREATE INDEX IF NOT EXISTS idx_render_jobs_status ON render_jobs(status)"); + db.run("CREATE INDEX IF NOT EXISTS idx_render_jobs_created ON render_jobs(created_at)"); + // 기존 테이블에 business_name 컬럼 추가 (존재하지 않을 경우를 대비해 try-catch 대신 별도 실행) // SQLite는 IF NOT EXISTS 컬럼 추가를 지원하지 않으므로, 에러를 무시하는 방식으로 처리하거나 스키마 버전을 관리해야 함. // 여기서는 간단히 컬럼 추가 시도 후 에러 무시 패턴을 사용. @@ -480,6 +638,239 @@ db.serialize(() => { FOREIGN KEY(user_id) REFERENCES users(id) )`); + // ============================================ + // 축제 테이블 (TourAPI 연동) + // ============================================ + db.run(`CREATE TABLE IF NOT EXISTS festivals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content_id TEXT UNIQUE NOT NULL, + content_type_id TEXT DEFAULT '15', + title TEXT NOT NULL, + overview TEXT, + addr1 TEXT, + addr2 TEXT, + zipcode TEXT, + area_code TEXT, + sigungu_code TEXT, + sido TEXT, + sigungu TEXT, + mapx REAL, + mapy REAL, + event_start_date TEXT, + event_end_date TEXT, + first_image TEXT, + first_image2 TEXT, + tel TEXT, + homepage TEXT, + place TEXT, + place_info TEXT, + play_time TEXT, + program TEXT, + use_fee TEXT, + age_limit TEXT, + sponsor1 TEXT, + sponsor1_tel TEXT, + sponsor2 TEXT, + sponsor2_tel TEXT, + sub_event TEXT, + booking_place TEXT, + is_active INTEGER DEFAULT 1, + view_count INTEGER DEFAULT 0, + last_synced_at TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + )`); + + // 축제 인덱스 + db.run("CREATE INDEX IF NOT EXISTS idx_festivals_area ON festivals(area_code, sigungu_code)"); + db.run("CREATE INDEX IF NOT EXISTS idx_festivals_date ON festivals(event_start_date, event_end_date)"); + db.run("CREATE INDEX IF NOT EXISTS idx_festivals_coords ON festivals(mapx, mapy)"); + + // ============================================ + // 전국 펜션 마스터 테이블 (TourAPI 연동) + // ============================================ + db.run(`CREATE TABLE IF NOT EXISTS public_pensions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + source_id TEXT, + content_id TEXT, + name TEXT NOT NULL, + name_normalized TEXT, + address TEXT, + road_address TEXT, + jibun_address TEXT, + zipcode TEXT, + sido TEXT, + sigungu TEXT, + eupmyeondong TEXT, + area_code TEXT, + sigungu_code TEXT, + mapx REAL, + mapy REAL, + tel TEXT, + homepage TEXT, + thumbnail TEXT, + images TEXT, + checkin_time TEXT, + checkout_time TEXT, + room_count INTEGER, + room_type TEXT, + facilities TEXT, + parking TEXT, + reservation_url TEXT, + pet_allowed INTEGER DEFAULT 0, + pickup_available INTEGER DEFAULT 0, + cooking_available INTEGER DEFAULT 0, + barbecue_available INTEGER DEFAULT 0, + business_status TEXT DEFAULT '영업중', + license_date TEXT, + closure_date TEXT, + is_verified INTEGER DEFAULT 0, + is_claimed INTEGER DEFAULT 0, + claimed_by INTEGER, + claimed_at TEXT, + view_count INTEGER DEFAULT 0, + last_synced_at TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (claimed_by) REFERENCES users(id) + )`); + + // 펜션 인덱스 + db.run("CREATE INDEX IF NOT EXISTS idx_public_pensions_sido ON public_pensions(sido)"); + db.run("CREATE INDEX IF NOT EXISTS idx_public_pensions_sigungu ON public_pensions(sido, sigungu)"); + db.run("CREATE INDEX IF NOT EXISTS idx_public_pensions_coords ON public_pensions(mapx, mapy)"); + db.run("CREATE INDEX IF NOT EXISTS idx_public_pensions_name ON public_pensions(name_normalized)"); + + // ============================================ + // 펜션-축제 매칭 테이블 + // ============================================ + db.run(`CREATE TABLE IF NOT EXISTS pension_festival_matches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pension_id INTEGER NOT NULL, + pension_type TEXT DEFAULT 'public', + festival_id INTEGER NOT NULL, + distance_km REAL, + travel_time_min INTEGER, + match_type TEXT, + match_score INTEGER DEFAULT 0, + is_featured INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (festival_id) REFERENCES festivals(id), + UNIQUE(pension_id, pension_type, festival_id) + )`); + + // 매칭 인덱스 + db.run("CREATE INDEX IF NOT EXISTS idx_matches_pension ON pension_festival_matches(pension_id, pension_type)"); + db.run("CREATE INDEX IF NOT EXISTS idx_matches_festival ON pension_festival_matches(festival_id)"); + + // ============================================ + // 지역코드 테이블 + // ============================================ + db.run(`CREATE TABLE IF NOT EXISTS area_codes ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + name_short TEXT, + name_en TEXT + )`); + + // 지역코드 초기 데이터 + const areaCodes = [ + ['1', '서울특별시', '서울', 'Seoul'], + ['2', '인천광역시', '인천', 'Incheon'], + ['3', '대전광역시', '대전', 'Daejeon'], + ['4', '대구광역시', '대구', 'Daegu'], + ['5', '광주광역시', '광주', 'Gwangju'], + ['6', '부산광역시', '부산', 'Busan'], + ['7', '울산광역시', '울산', 'Ulsan'], + ['8', '세종특별자치시', '세종', 'Sejong'], + ['31', '경기도', '경기', 'Gyeonggi'], + ['32', '강원특별자치도', '강원', 'Gangwon'], + ['33', '충청북도', '충북', 'Chungbuk'], + ['34', '충청남도', '충남', 'Chungnam'], + ['35', '경상북도', '경북', 'Gyeongbuk'], + ['36', '경상남도', '경남', 'Gyeongnam'], + ['37', '전북특별자치도', '전북', 'Jeonbuk'], + ['38', '전라남도', '전남', 'Jeonnam'], + ['39', '제주특별자치도', '제주', 'Jeju'], + ]; + areaCodes.forEach(([code, name, nameShort, nameEn]) => { + db.run("INSERT OR IGNORE INTO area_codes (code, name, name_short, name_en) VALUES (?, ?, ?, ?)", + [code, name, nameShort, nameEn]); + }); + + // pension_profiles에 좌표 컬럼 추가 (기존 테이블용) + db.run("ALTER TABLE pension_profiles ADD COLUMN mapx REAL", (err) => {}); + db.run("ALTER TABLE pension_profiles ADD COLUMN mapy REAL", (err) => {}); + db.run("ALTER TABLE pension_profiles ADD COLUMN area_code TEXT", (err) => {}); + db.run("ALTER TABLE pension_profiles ADD COLUMN sigungu_code TEXT", (err) => {}); + db.run("ALTER TABLE pension_profiles ADD COLUMN public_pension_id INTEGER", (err) => {}); + + // ============================================ + // API 사용량 추적 테이블 (Gemini, Suno 등) + // ============================================ + db.run(`CREATE TABLE IF NOT EXISTS api_usage_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service TEXT NOT NULL, + model TEXT, + endpoint TEXT, + user_id INTEGER, + tokens_input INTEGER DEFAULT 0, + tokens_output INTEGER DEFAULT 0, + image_count INTEGER DEFAULT 0, + audio_seconds REAL DEFAULT 0, + video_seconds REAL DEFAULT 0, + status TEXT DEFAULT 'success', + error_message TEXT, + latency_ms INTEGER DEFAULT 0, + cost_estimate REAL DEFAULT 0, + metadata TEXT, + createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL + )`); + + // API 사용량 인덱스 + db.run("CREATE INDEX IF NOT EXISTS idx_api_usage_service ON api_usage_logs(service, createdAt)"); + db.run("CREATE INDEX IF NOT EXISTS idx_api_usage_user ON api_usage_logs(user_id, createdAt)"); + db.run("CREATE INDEX IF NOT EXISTS idx_api_usage_date ON api_usage_logs(createdAt)"); + + // ============================================ + // 시스템 설정 테이블 (쿠키, API 키 등) + // ============================================ + db.run(`CREATE TABLE IF NOT EXISTS system_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + setting_key TEXT UNIQUE NOT NULL, + setting_value TEXT, + description TEXT, + is_encrypted INTEGER DEFAULT 0, + updated_by INTEGER, + createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, + updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(updated_by) REFERENCES users(id) ON DELETE SET NULL + )`); + + // ============================================ + // API 일별 집계 테이블 + // ============================================ + db.run(`CREATE TABLE IF NOT EXISTS api_usage_daily ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + service TEXT NOT NULL, + model TEXT, + total_calls INTEGER DEFAULT 0, + success_count INTEGER DEFAULT 0, + error_count INTEGER DEFAULT 0, + total_tokens_input INTEGER DEFAULT 0, + total_tokens_output INTEGER DEFAULT 0, + total_images INTEGER DEFAULT 0, + total_audio_seconds REAL DEFAULT 0, + total_video_seconds REAL DEFAULT 0, + total_cost_estimate REAL DEFAULT 0, + avg_latency_ms REAL DEFAULT 0, + createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(date, service, model) + )`); + // 기본 관리자 계정 생성 (존재하지 않을 경우) const adminUsername = 'admin'; const adminPassword = 'admin123'; // 초기 비밀번호 diff --git a/server/geminiBackendService.js b/server/geminiBackendService.js index da8c05d..5b445f7 100644 --- a/server/geminiBackendService.js +++ b/server/geminiBackendService.js @@ -2,6 +2,209 @@ const { GoogleGenAI, Type, Modality } = require('@google/genai'); 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) => { if (config.gender === 'Female') { 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(', ') : '일반 펜션'; + // 근처 축제 정보 처리 + 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 = ` 역할: 전문 ${targetLang} 카피라이터 및 작사가. 클라이언트: ${info.name} @@ -86,6 +310,7 @@ const generateCreativeContent = async (info) => { 모드: ${info.audioMode} (Song=노래, Narration=내레이션) 스타일: ${info.audioMode === 'Song' ? info.musicGenre : info.ttsConfig.tone} 언어: **${targetLang}** 로 작성 필수. + ${festivalContext} 과제 1: 임팩트 있는 **${targetLang}** 광고 헤드라인 4개를 작성하라. - **완벽한 ${targetLang} 사용**: 자연스럽고 세련된 현지 마케팅 표현 사용. @@ -198,29 +423,28 @@ const generateAdvancedSpeech = async ( }; /** - * 서버 사이드에서 광고 포스터 생성 - Gemini 3 Pro Image + * 서버 사이드에서 광고 포스터 생성 - 모델 우선순위 기반 폴백 시스템 * 업로드된 이미지들을 합성하고 브랜드 분위기에 맞는 고화질 포스터를 생성합니다. - * + * * @param {object} info - 비즈니스 정보 객체 (BusinessInfo와 유사) * @param {Array} info.images - Base64 인코딩된 이미지 데이터 배열 ({ mimeType, base64 }) * @param {string} info.name - 브랜드 이름 * @param {string} info.description - 브랜드 설명 * @param {string} info.aspectRatio - 화면 비율 * @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) => { if (!GEMINI_API_KEY) { throw new Error("Gemini API Key가 서버에 설정되지 않았습니다."); } const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY }); - + const imageParts = info.images.map((imageData) => ({ inlineData: { mimeType: imageData.mimeType, data: imageData.base64 } })); - - const model = 'gemini-3-pro-image-preview'; - + let prompt = ''; if (imageParts.length > 0) { @@ -228,7 +452,7 @@ const generateAdPoster = async (info) => { prompt = ` 역할: 전문 아트 디렉터. 과제: 브랜드 "${info.name}"를 위한 단 하나의, 조화롭고 수상 경력에 빛나는 광고 포스터를 제작하라. - + 지침: 1. **시각적 합성(Composite Visuals)**: 제공된 여러 참조 이미지를 바탕으로 예술적으로 재창조하라. 필요하다면 요소를 추가하거나 조명을 개선하여 퀄리티를 높여라. 2. 테마: ${info.description}. @@ -239,7 +463,7 @@ const generateAdPoster = async (info) => { prompt = ` 역할: 사진 편집자. 과제: 제공된 이미지들을 사용하여 하나의 깔끔한 홍보 이미지를 만들어라. - + **매우 중요한 제약사항 (Strict Constraints)**: 1. **새로운 사물 생성 금지**: 제공된 이미지에 없는 물체, 사람, 배경을 절대로 새로 그려넣지 마라. 2. **원본 유지**: 제공된 이미지들의 톤과 느낌을 최대한 유지하며 자연스럽게 합성하라. (Digital Collage). @@ -255,38 +479,55 @@ const generateAdPoster = async (info) => { prompt = ` 역할: 세계적인 상업 사진작가 및 아트 디렉터. 과제: 브랜드 "${info.name}"를 홍보하기 위한 최고급 상업 광고 사진을 생성하라. - + 브랜드 설명: "${info.description}" - + 지침: 1. **이미지 생성**: 위 브랜드 설명에 완벽하게 부합하는, 디테일이 살아있는 고해상도 사진을 창조하라. 실제 매장이나 제품을 촬영한 것 같은 사실감을 주어라. - 2. 스타일: + 2. 스타일: - 조명: 드라마틱하고 따뜻한 시네마틱 조명. - 퀄리티: 8k UHD, 초고화질, 잡지 커버 수준의 디테일. - 분위기: 고객이 방문하고 싶게 만드는 매력적이고 환영하는 분위기. 3. 구도: ${info.aspectRatio === '9:16' ? '9:16 세로 비율 (Vertical/Portrait) 필수. 스마트폰 전체 화면용. 가로 사진을 90도 회전시키지 마라.' : '16:9 와이드 비율 (Horizontal). 중앙이나 한쪽에 텍스트를 배치할 수 있는 여백을 고려한 구도.'} 4. 금지: 흐릿하거나 왜곡된 이미지, 부자연스러운 텍스트 생성 금지. - + 이 이미지는 뮤직 비디오의 메인 배경으로 사용될 것이다. `; } - const response = await ai.models.generateContent({ - model, - contents: { - parts: [ - { text: prompt }, - ...imageParts - ] - }, - }); + try { + // 우선순위 기반 모델 폴백 시스템 사용 + const result = await generateImageWithFallback(ai, prompt, imageParts, { + aspectRatio: info.aspectRatio || '16:9', + userId: info.userId + }); - for (const part of response.candidates?.[0]?.content?.parts || []) { - if (part.inlineData) { - return { base64: part.inlineData.data, mimeType: part.inlineData.mimeType || 'image/png' }; - } + console.log(`[Ad Poster] Generated successfully using: ${result.modelUsed}`); + return result; + + } 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가 서버에 설정되지 않았습니다."); } 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 = [ "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({ model, contents: { parts: [{ text: prompt }] }, + config: { + responseModalities: [Modality.IMAGE], + } }); 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} - 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 = { generateCreativeContent, generateAdvancedSpeech, @@ -802,4 +1213,5 @@ module.exports = { extractTextEffectFromImage, generateVideoBackground, generateYouTubeSEO, + analyzeBusinessDNA, }; \ No newline at end of file diff --git a/server/index.js b/server/index.js index 31bd168..f26a8e4 100644 --- a/server/index.js +++ b/server/index.js @@ -1190,8 +1190,9 @@ app.post('/api/naver/crawl', async (req, res) => { console.log(`[Naver] ID 추출됨: ${placeId}`); const headers = { ...REQUEST_HEADERS }; - if (process.env.NAVER_COOKIES) { - headers['Cookie'] = process.env.NAVER_COOKIES.trim().replace(/^"|"$/g, ''); + const naverCookies = getCookie('naver') || process.env.NAVER_COOKIES; + if (naverCookies) { + headers['Cookie'] = naverCookies.trim().replace(/^"|"$/g, ''); } const response = await retry(async () => { @@ -1208,7 +1209,10 @@ app.post('/api/naver/crawl', async (req, res) => { const base = business.base || {}; // 이미지 추출 (공식 이미지 + 사용자 제공 이미지) - const rawImgs = [...(business.images?.images || []), ...(business.cpImages?.images || [])]; + const rawImgs = [ + ...(business.images?.images || []), // 공식 이미지 + ...(business.cpImages?.images || []) // 사용자 제공 이미지 (UGC) + ]; const images = []; const seen = new Set(); // 중복 제거하며 URL 수집 @@ -1249,6 +1253,222 @@ app.post('/api/naver/crawl', async (req, res) => { } }); +// --- INSTAGRAM API (크롤링) --- +const INSTAGRAM_HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "*/*", + "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7", + "X-IG-App-ID": "936619743392459", + "X-Requested-With": "XMLHttpRequest", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + "Referer": "https://www.instagram.com/" +}; + +// 쿠키를 메모리에 캐시 (DB에서 로드) +let cachedCookies = { + naver: process.env.NAVER_COOKIES || '', + instagram: process.env.INSTAGRAM_COOKIES || '' +}; + +// 서버 시작 시 DB에서 쿠키 로드 +const loadCookiesFromDB = () => { + db.all("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('naver_cookies', 'instagram_cookies')", [], (err, rows) => { + if (!err && rows) { + rows.forEach(row => { + if (row.setting_key === 'naver_cookies' && row.setting_value) { + cachedCookies.naver = row.setting_value; + } + if (row.setting_key === 'instagram_cookies' && row.setting_value) { + cachedCookies.instagram = row.setting_value; + } + }); + console.log('[System] 쿠키 설정 로드 완료'); + } + }); +}; +// 서버 시작 후 약간의 지연을 두고 로드 (DB 초기화 대기) +setTimeout(loadCookiesFromDB, 1000); + +// 쿠키 가져오기 헬퍼 함수 +const getCookie = (type) => { + return cachedCookies[type] || ''; +}; + +app.post('/api/instagram/crawl', async (req, res) => { + const { url } = req.body; + if (!url) return res.status(400).json({ error: "URL이 필요합니다." }); + + console.log(`[Instagram] 크롤링 시작: ${url}`); + + try { + // URL에서 username 추출 + let username = ""; + + // instagram.com/username 또는 instagram.com/username/ 형식 + const match = url.match(/instagram\.com\/([a-zA-Z0-9._]+)/); + if (match && match[1] && !['p', 'reel', 'stories', 'explore'].includes(match[1])) { + username = match[1]; + } else { + return res.status(400).json({ error: "유효한 인스타그램 프로필 URL이 아닙니다. 예: https://instagram.com/username" }); + } + + console.log(`[Instagram] Username 추출됨: ${username}`); + + const headers = { ...INSTAGRAM_HEADERS }; + const instagramCookies = getCookie('instagram'); + if (instagramCookies) { + headers['Cookie'] = instagramCookies.trim().replace(/^"|"$/g, ''); + } else { + console.warn('[Instagram] 쿠키가 설정되지 않았습니다. 일부 프로필은 접근이 제한될 수 있습니다.'); + } + + // Instagram GraphQL API 호출 + const apiUrl = `https://www.instagram.com/api/v1/users/web_profile_info/?username=${username}`; + + const response = await retry(async () => { + return await axios.get(apiUrl, { headers, timeout: 10000 }); + }, 3, 1000); + + const userData = response.data?.data?.user; + if (!userData) { + return res.status(404).json({ error: "사용자를 찾을 수 없습니다. 비공개 계정이거나 존재하지 않는 사용자입니다." }); + } + + // 프로필 정보 추출 + const profileName = userData.full_name || username; + const biography = userData.biography || ''; + + // 최근 게시물에서 이미지 추출 + const edges = userData.edge_owner_to_timeline_media?.edges || []; + const images = []; + const seen = new Set(); + + for (const edge of edges) { + const node = edge.node; + + // 단일 이미지 + if (node.display_url && !seen.has(node.display_url)) { + seen.add(node.display_url); + images.push(node.display_url); + } + + // 캐러셀 (여러 이미지) + if (node.edge_sidecar_to_children?.edges) { + for (const child of node.edge_sidecar_to_children.edges) { + const childUrl = child.node?.display_url; + if (childUrl && !seen.has(childUrl)) { + seen.add(childUrl); + images.push(childUrl); + } + } + } + } + + // 셔플 + const shuffledImages = shuffleArray(images); + + console.log(`[Instagram] ${profileName}: 총 ${images.length}장 이미지 발견`); + + res.json({ + name: profileName, + description: biography, + images: shuffledImages, + totalImages: images.length, + username: username, + address: '', + category: 'Instagram' + }); + + } catch (e) { + console.error("[Instagram] 오류:", e.message); + if (e.response?.status === 401 || e.response?.status === 403) { + res.status(401).json({ error: "인스타그램 인증이 필요합니다. 관리자에게 쿠키 설정을 요청하세요." }); + } else { + res.status(500).json({ error: "크롤링 실패", details: e.message }); + } + } +}); + +// --- 관리자 쿠키 관리 API --- +// 쿠키 조회 (관리자 전용) +app.get('/api/admin/cookies', authenticateToken, requireAdmin, (req, res) => { + db.all("SELECT setting_key, setting_value, description, updatedAt FROM system_settings WHERE setting_key IN ('naver_cookies', 'instagram_cookies')", [], (err, rows) => { + if (err) return res.status(500).json({ error: err.message }); + + // 쿠키 값 마스킹 (앞 20자만 표시) + const result = { + naver_cookies: { value: '', masked: '', updatedAt: null }, + instagram_cookies: { value: '', masked: '', updatedAt: null } + }; + + rows?.forEach(row => { + const val = row.setting_value || ''; + result[row.setting_key] = { + value: val, + masked: val.length > 20 ? val.substring(0, 20) + '...' + ` (${val.length}자)` : val, + updatedAt: row.updatedAt + }; + }); + + // .env에서 로드된 값도 표시 (DB에 없는 경우) + if (!result.naver_cookies.value && process.env.NAVER_COOKIES) { + result.naver_cookies.value = process.env.NAVER_COOKIES; + result.naver_cookies.masked = '.env에서 로드됨'; + result.naver_cookies.source = 'env'; + } + if (!result.instagram_cookies.value && process.env.INSTAGRAM_COOKIES) { + result.instagram_cookies.value = process.env.INSTAGRAM_COOKIES; + result.instagram_cookies.masked = '.env에서 로드됨'; + result.instagram_cookies.source = 'env'; + } + + res.json(result); + }); +}); + +// 쿠키 수정 (관리자 전용) +app.put('/api/admin/cookies', authenticateToken, requireAdmin, async (req, res) => { + const { naver_cookies, instagram_cookies } = req.body; + const userId = req.user.id; + + try { + const updateCookie = (key, value) => { + return new Promise((resolve, reject) => { + db.run(`INSERT INTO system_settings (setting_key, setting_value, updated_by, updatedAt) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(setting_key) DO UPDATE SET + setting_value = excluded.setting_value, + updated_by = excluded.updated_by, + updatedAt = datetime('now')`, + [key, value || '', userId], + function(err) { + if (err) reject(err); + else resolve(); + } + ); + }); + }; + + if (naver_cookies !== undefined) { + await updateCookie('naver_cookies', naver_cookies); + cachedCookies.naver = naver_cookies || ''; + } + if (instagram_cookies !== undefined) { + await updateCookie('instagram_cookies', instagram_cookies); + cachedCookies.instagram = instagram_cookies || ''; + } + + console.log(`[Admin] 쿠키 설정 업데이트됨 (by user ${userId})`); + res.json({ success: true, message: '쿠키가 업데이트되었습니다.' }); + + } catch (e) { + console.error("[Admin] 쿠키 업데이트 오류:", e.message); + res.status(500).json({ error: "쿠키 업데이트 실패" }); + } +}); + // --- GENERIC IMAGE PROXY (for Naver etc.) --- app.get('/api/proxy/image', async (req, res) => { try { @@ -1559,10 +1779,526 @@ app.post('/api/gemini/search-business', authenticateToken, async (req, res) => { } }); -// --- VIDEO RENDER API --- +// DNA 분석 (Google Search Grounding) +app.post('/api/gemini/analyze-dna', authenticateToken, async (req, res) => { + try { + const { nameOrUrl, images } = req.body; + if (!nameOrUrl) { + return res.status(400).json({ error: '펜션 이름 또는 URL이 필요합니다.' }); + } + const dna = await geminiService.analyzeBusinessDNA(nameOrUrl, images || [], req.user.id); + res.json({ dna }); + } catch (error) { + console.error("[Gemini Proxy] Analyze DNA Error:", error); + res.status(500).json({ error: error.message }); + } +}); + +// --- VIDEO RENDER QUEUE API --- /** - * 서버 사이드 영상 렌더링 엔드포인트 - * Puppeteer로 슬라이드쇼를 녹화하고 FFmpeg로 오디오를 합성합니다. + * 렌더링 작업 큐 시스템 + * - 작업 시작 시 크레딧 선차감 + * - 백그라운드에서 렌더링 처리 + * - 사용자가 로그아웃/페이지 이동해도 작업 계속 + */ + +// 동시 렌더링 제한 +const MAX_CONCURRENT_RENDERS = 3; +let activeRenderCount = 0; + +// 작업 ID 생성 +function generateJobId() { + return `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * POST /api/render/start + * 렌더링 작업 시작 (크레딧 선차감, job_id 즉시 반환) + * 계정당 동시 렌더링 1개로 제한 + */ +app.post('/api/render/start', authenticateToken, async (req, res) => { + const userId = req.user.id; + const jobId = generateJobId(); + + console.log(`[RenderQueue] 작업 요청: ${jobId} by user ${userId}`); + + try { + // 0. 계정당 동시 렌더링 제한 체크 (pending 또는 processing 작업이 있으면 거부) + const existingJob = await new Promise((resolve, reject) => { + db.get( + `SELECT id, status, progress, created_at FROM render_jobs + WHERE user_id = ? AND status IN ('pending', 'processing') + ORDER BY created_at DESC LIMIT 1`, + [userId], + (err, row) => err ? reject(err) : resolve(row) + ); + }); + + if (existingJob) { + console.log(`[RenderQueue] 사용자 ${userId} 이미 진행 중인 작업 있음: ${existingJob.id} (${existingJob.status})`); + return res.status(429).json({ + success: false, + error: '이미 렌더링 작업이 진행 중입니다. 완료 후 다시 시도해주세요.', + errorCode: 'RENDER_IN_PROGRESS', + existingJobId: existingJob.id, + existingJobStatus: existingJob.status, + existingJobProgress: existingJob.progress + }); + } + + // 1. 사용자 크레딧 확인 + const user = await new Promise((resolve, reject) => { + db.get("SELECT credits, role FROM users WHERE id = ?", [userId], (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); + + // 관리자가 아니고 크레딧이 부족한 경우 + if (user.role !== 'admin' && (user.credits === null || user.credits <= 0)) { + return res.status(403).json({ + success: false, + error: '크레딧이 부족합니다. 추가 크레딧을 요청해주세요.', + errorCode: 'INSUFFICIENT_CREDITS', + credits: user.credits || 0 + }); + } + + // 2. 크레딧 선차감 (관리자는 제외) + let creditsCharged = 0; + if (user.role !== 'admin') { + creditsCharged = 1; + await new Promise((resolve, reject) => { + db.run( + "UPDATE users SET credits = credits - 1 WHERE id = ?", + [userId], + (err) => err ? reject(err) : resolve() + ); + }); + + // 크레딧 변동 기록 + const newBalance = (user.credits || 0) - 1; + await new Promise((resolve, reject) => { + db.run( + `INSERT INTO credit_history (user_id, amount, type, description, balance_after) + VALUES (?, ?, ?, ?, ?)`, + [userId, -1, 'render', `영상 렌더링 (${jobId})`, newBalance], + (err) => err ? reject(err) : resolve() + ); + }); + + console.log(`[RenderQueue] 크레딧 차감: user ${userId}, 잔액 ${newBalance}`); + } + + // 3. 입력 데이터 추출 + const { + posterBase64, + audioBase64, + imagesBase64, + adCopy = [], + textEffect = 'effect-fade', + businessName = 'CastAD', + aspectRatio = '9:16', + pensionId + } = req.body; + + // 4. 작업 큐에 등록 + const inputData = JSON.stringify({ + posterBase64, + audioBase64, + imagesBase64, + adCopy, + textEffect, + businessName, + aspectRatio + }); + + await new Promise((resolve, reject) => { + db.run( + `INSERT INTO render_jobs (id, user_id, pension_id, status, input_data, credits_charged, created_at) + VALUES (?, ?, ?, 'pending', ?, ?, datetime('now'))`, + [jobId, userId, pensionId || null, inputData, creditsCharged], + (err) => err ? reject(err) : resolve() + ); + }); + + console.log(`[RenderQueue] 작업 등록 완료: ${jobId}`); + + // 5. 즉시 응답 (작업 ID 반환) + res.json({ + success: true, + jobId, + message: '렌더링 작업이 시작되었습니다.', + creditsCharged, + creditsRemaining: user.role === 'admin' ? 'unlimited' : (user.credits || 0) - creditsCharged + }); + + // 6. 백그라운드에서 작업 처리 시작 + processRenderQueue(); + + } catch (error) { + console.error(`[RenderQueue] 작업 등록 실패: ${jobId}`, error); + res.status(500).json({ + success: false, + error: '렌더링 작업 등록에 실패했습니다.', + details: error.message + }); + } +}); + +/** + * GET /api/render/status/:jobId + * 작업 상태 조회 + */ +app.get('/api/render/status/:jobId', authenticateToken, async (req, res) => { + const { jobId } = req.params; + const userId = req.user.id; + + try { + const job = await new Promise((resolve, reject) => { + db.get( + `SELECT id, status, progress, output_path, history_id, error_message, + credits_charged, credits_refunded, created_at, started_at, completed_at + FROM render_jobs WHERE id = ? AND user_id = ?`, + [jobId, userId], + (err, row) => err ? reject(err) : resolve(row) + ); + }); + + if (!job) { + return res.status(404).json({ success: false, error: '작업을 찾을 수 없습니다.' }); + } + + res.json({ + success: true, + job: { + ...job, + downloadUrl: job.output_path ? `/downloads/${path.basename(path.dirname(job.output_path))}/final.mp4` : null + } + }); + + } catch (error) { + console.error(`[RenderQueue] 상태 조회 실패: ${jobId}`, error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * GET /api/render/jobs + * 내 작업 목록 조회 + */ +app.get('/api/render/jobs', authenticateToken, async (req, res) => { + const userId = req.user.id; + const { status, limit = 20, offset = 0 } = req.query; + + try { + let query = `SELECT id, status, progress, output_path, history_id, error_message, + credits_charged, credits_refunded, created_at, started_at, completed_at + FROM render_jobs WHERE user_id = ?`; + const params = [userId]; + + if (status) { + query += ` AND status = ?`; + params.push(status); + } + + query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`; + params.push(parseInt(limit), parseInt(offset)); + + const jobs = await new Promise((resolve, reject) => { + db.all(query, params, (err, rows) => err ? reject(err) : resolve(rows)); + }); + + // 진행 중인 작업 수 + const pendingCount = await new Promise((resolve, reject) => { + db.get( + `SELECT COUNT(*) as count FROM render_jobs WHERE user_id = ? AND status IN ('pending', 'processing')`, + [userId], + (err, row) => err ? reject(err) : resolve(row?.count || 0) + ); + }); + + res.json({ + success: true, + jobs: jobs.map(job => ({ + ...job, + downloadUrl: job.output_path ? `/downloads/${path.basename(path.dirname(job.output_path))}/final.mp4` : null + })), + pendingCount + }); + + } catch (error) { + console.error('[RenderQueue] 작업 목록 조회 실패:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 백그라운드 렌더링 Worker + * pending 상태의 작업을 순차적으로 처리 + */ +async function processRenderQueue() { + // 동시 렌더링 제한 체크 + if (activeRenderCount >= MAX_CONCURRENT_RENDERS) { + console.log(`[RenderWorker] 최대 동시 렌더링 수 (${MAX_CONCURRENT_RENDERS}) 도달, 대기`); + return; + } + + // pending 작업 가져오기 + const job = await new Promise((resolve, reject) => { + db.get( + `SELECT * FROM render_jobs WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1`, + (err, row) => err ? reject(err) : resolve(row) + ); + }).catch(err => { + console.error('[RenderWorker] 작업 조회 실패:', err); + return null; + }); + + if (!job) { + return; // 대기 중인 작업 없음 + } + + activeRenderCount++; + console.log(`[RenderWorker] 작업 시작: ${job.id} (활성: ${activeRenderCount}/${MAX_CONCURRENT_RENDERS})`); + + try { + // 상태 업데이트: processing + await new Promise((resolve, reject) => { + db.run( + `UPDATE render_jobs SET status = 'processing', started_at = datetime('now'), progress = 5 WHERE id = ?`, + [job.id], + (err) => err ? reject(err) : resolve() + ); + }); + + // 입력 데이터 파싱 + const inputData = JSON.parse(job.input_data); + const { + posterBase64, + audioBase64, + imagesBase64, + adCopy = [], + textEffect = 'effect-fade', + businessName = 'CastAD', + aspectRatio = '9:16' + } = inputData; + + // 프로젝트 폴더 생성 + const projectFolder = `render_${job.id}`; + const projectPath = path.join(DOWNLOADS_DIR, projectFolder); + fs.mkdirSync(projectPath, { recursive: true }); + + // 진행률 업데이트 함수 + const updateProgress = async (progress) => { + await new Promise((resolve) => { + db.run(`UPDATE render_jobs SET progress = ? WHERE id = ?`, [progress, job.id], resolve); + }); + }; + + await updateProgress(10); + + // 파일 저장 + const audioPath = path.join(projectPath, 'audio.mp3'); + const videoPath = path.join(projectPath, 'video.webm'); + const finalPath = path.join(projectPath, 'final.mp4'); + + if (audioBase64) { + fs.writeFileSync(audioPath, Buffer.from(audioBase64, 'base64')); + } + + await updateProgress(20); + + // 이미지 저장 + const imagePaths = []; + if (imagesBase64 && imagesBase64.length > 0) { + for (let i = 0; i < imagesBase64.length; i++) { + const imgPath = path.join(projectPath, `image_${i}.jpg`); + const imgData = imagesBase64[i].replace(/^data:image\/\w+;base64,/, ''); + fs.writeFileSync(imgPath, Buffer.from(imgData, 'base64')); + imagePaths.push(imgPath); + } + } + + // 포스터 저장 + if (posterBase64) { + const posterPath = path.join(projectPath, 'poster.jpg'); + const posterData = posterBase64.replace(/^data:image\/\w+;base64,/, ''); + fs.writeFileSync(posterPath, Buffer.from(posterData, 'base64')); + if (imagePaths.length === 0) { + imagePaths.push(posterPath); + } + } + + await updateProgress(30); + + // 비디오 크기 결정 + const isVertical = aspectRatio === '9:16'; + const width = isVertical ? 540 : 960; + const height = isVertical ? 960 : 540; + + // HTML 템플릿 생성 + const htmlContent = generateRenderHTML({ + imagePaths: imagePaths.map(p => `file://${p}`), + adCopy, + textEffect, + businessName, + width, + height + }); + + const htmlPath = path.join(projectPath, 'render.html'); + fs.writeFileSync(htmlPath, htmlContent); + + await updateProgress(40); + + // Puppeteer 녹화 + const browser = await puppeteer.launch({ + headless: 'new', + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-web-security'] + }); + + const page = await browser.newPage(); + await page.setViewport({ width, height }); + + const recorder = new PuppeteerScreenRecorder(page, { + fps: 30, + ffmpeg_Path: null, + videoFrame: { width, height }, + aspectRatio: isVertical ? '9:16' : '16:9' + }); + + await page.goto(`file://${htmlPath}`, { waitUntil: 'networkidle0' }); + await recorder.start(videoPath); + + await updateProgress(50); + + const durationMs = Math.max(30000, adCopy.length * 5000, imagePaths.length * 5000); + await page.evaluate(() => { + window.startAnimation && window.startAnimation(); + }); + + await new Promise(resolve => setTimeout(resolve, durationMs)); + + await recorder.stop(); + await browser.close(); + + await updateProgress(80); + + // FFmpeg 오디오 합성 + if (fs.existsSync(audioPath)) { + await new Promise((resolve, reject) => { + const ffmpegCmd = `ffmpeg -y -i "${videoPath}" -i "${audioPath}" -c:v libx264 -preset fast -crf 23 -c:a aac -b:a 128k -shortest "${finalPath}"`; + exec(ffmpegCmd, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + } else { + await new Promise((resolve, reject) => { + const ffmpegCmd = `ffmpeg -y -i "${videoPath}" -c:v libx264 -preset fast -crf 23 "${finalPath}"`; + exec(ffmpegCmd, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + + await updateProgress(90); + + // History 레코드 생성 + const historyId = await new Promise((resolve, reject) => { + db.run( + `INSERT INTO history (user_id, business_name, details, final_video_path, pension_id) VALUES (?, ?, ?, ?, ?)`, + [job.user_id, businessName, JSON.stringify({ adCopy, textEffect }), `/downloads/${projectFolder}/final.mp4`, job.pension_id], + function(err) { + if (err) reject(err); + else resolve(this.lastID); + } + ); + }); + + // 작업 완료 업데이트 + await new Promise((resolve, reject) => { + db.run( + `UPDATE render_jobs SET status = 'completed', progress = 100, output_path = ?, history_id = ?, completed_at = datetime('now') WHERE id = ?`, + [finalPath, historyId, job.id], + (err) => err ? reject(err) : resolve() + ); + }); + + console.log(`[RenderWorker] 작업 완료: ${job.id}`); + + } catch (error) { + console.error(`[RenderWorker] 작업 실패: ${job.id}`, error); + + // 작업 실패 처리 + await new Promise((resolve) => { + db.run( + `UPDATE render_jobs SET status = 'failed', error_message = ?, completed_at = datetime('now') WHERE id = ?`, + [error.message, job.id], + resolve + ); + }); + + // 크레딧 환불 (실패 시) + if (job.credits_charged > 0) { + await new Promise((resolve) => { + db.run( + `UPDATE users SET credits = credits + ? WHERE id = ?`, + [job.credits_charged, job.user_id], + resolve + ); + }); + + await new Promise((resolve) => { + db.run( + `UPDATE render_jobs SET credits_refunded = ? WHERE id = ?`, + [job.credits_charged, job.id], + resolve + ); + }); + + // 환불 기록 + await new Promise((resolve) => { + db.run( + `INSERT INTO credit_history (user_id, amount, type, description) + VALUES (?, ?, ?, ?)`, + [job.user_id, job.credits_charged, 'refund', `렌더링 실패 환불 (${job.id})`], + resolve + ); + }); + + console.log(`[RenderWorker] 크레딧 환불: user ${job.user_id}, ${job.credits_charged} 크레딧`); + } + + } finally { + activeRenderCount--; + + // 다음 작업 처리 + setTimeout(() => processRenderQueue(), 100); + } +} + +// 서버 시작 시 미처리 작업 복구 +setTimeout(() => { + console.log('[RenderWorker] 미처리 작업 확인...'); + + // processing 상태로 남은 작업을 pending으로 복구 + db.run( + `UPDATE render_jobs SET status = 'pending', started_at = NULL, progress = 0 WHERE status = 'processing'`, + (err) => { + if (!err) { + processRenderQueue(); + } + } + ); +}, 3000); + +// --- VIDEO RENDER API (기존 - 하위 호환성 유지) --- +/** + * 서버 사이드 영상 렌더링 엔드포인트 (동기식 - deprecated) + * 새로운 /api/render/start 사용 권장 */ app.post('/render', authenticateToken, async (req, res) => { const startTime = Date.now(); @@ -2257,6 +2993,526 @@ app.post('/api/profile/pension/:id/default', authenticateToken, (req, res) => { }); }); +// ==================== PENSION IMAGES MANAGEMENT ==================== + +/** + * 펜션 이미지 저장 (Base64) + * POST /api/profile/pension/:id/images + * body: { images: [{ base64, mimeType, filename, originalUrl, source, is_priority }] } + * source: 'crawl' | 'manual' | 'upload' + * is_priority: 1 = 수동 업로드 우선순위 (이벤트성) + */ +app.post('/api/profile/pension/:id/images', authenticateToken, async (req, res) => { + const userId = req.user.id; + const pensionId = req.params.id; + const { images } = req.body; + + if (!images || !Array.isArray(images) || images.length === 0) { + return res.status(400).json({ error: '이미지가 필요합니다.' }); + } + + // 펜션 소유권 확인 + db.get(`SELECT id FROM pension_profiles WHERE id = ? AND user_id = ?`, [pensionId, userId], async (err, row) => { + if (err) return res.status(500).json({ error: err.message }); + if (!row) return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); + + try { + // 펜션 이미지 디렉토리 생성 + const pensionImgDir = path.join(DOWNLOADS_DIR, 'pension_images', pensionId.toString()); + if (!fs.existsSync(pensionImgDir)) { + fs.mkdirSync(pensionImgDir, { recursive: true }); + } + + const savedImages = []; + for (let i = 0; i < images.length; i++) { + const img = images[i]; + const ext = img.mimeType?.split('/')[1] || 'jpg'; + const filename = img.filename || `pension_${pensionId}_${Date.now()}_${i}.${ext}`; + const filePath = path.join(pensionImgDir, filename); + + // Base64 디코딩 및 저장 + const buffer = Buffer.from(img.base64, 'base64'); + fs.writeFileSync(filePath, buffer); + + // 수동 업로드면 우선순위 설정 + const source = img.source || 'crawl'; + const isPriority = img.is_priority || (source === 'manual' || source === 'upload') ? 1 : 0; + + // DB에 저장 (is_priority 포함) + const relativePath = `/downloads/pension_images/${pensionId}/${filename}`; + await new Promise((resolve, reject) => { + db.run(`INSERT INTO pension_images (pension_id, user_id, filename, original_url, file_path, file_size, mime_type, source, is_priority) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [pensionId, userId, filename, img.originalUrl || null, relativePath, buffer.length, img.mimeType || 'image/jpeg', source, isPriority], + function(err) { + if (err) reject(err); + else { + savedImages.push({ id: this.lastID, filename, path: relativePath, is_priority: isPriority }); + resolve(); + } + }); + }); + } + + res.json({ success: true, savedCount: savedImages.length, images: savedImages }); + } catch (error) { + console.error('펜션 이미지 저장 실패:', error); + res.status(500).json({ error: '이미지 저장에 실패했습니다.' }); + } + }); +}); + +/** + * 펜션 이미지 목록 조회 (소스별 필터링 지원) + * GET /api/profile/pension/:id/images?source=naver|google|instagram|upload|all + */ +app.get('/api/profile/pension/:id/images', authenticateToken, (req, res) => { + const userId = req.user.id; + const pensionId = req.params.id; + const sourceFilter = req.query.source || 'all'; + + // 펜션 소유권 확인 + db.get(`SELECT id FROM pension_profiles WHERE id = ? AND user_id = ?`, [pensionId, userId], (err, row) => { + if (err) return res.status(500).json({ error: err.message }); + if (!row) return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); + + // 소스별 필터링 쿼리 구성 + let query = `SELECT id, filename, original_url, file_path, file_size, mime_type, source, is_priority, createdAt + FROM pension_images WHERE pension_id = ?`; + const params = [pensionId]; + + if (sourceFilter !== 'all') { + query += ` AND source = ?`; + params.push(sourceFilter); + } + + query += ` ORDER BY is_priority DESC, createdAt DESC`; + + db.all(query, params, (err, images) => { + if (err) return res.status(500).json({ error: err.message }); + res.json(images || []); + }); + }); +}); + +/** + * 펜션 이미지 소스별 통계 조회 + * GET /api/profile/pension/:id/images/stats + */ +app.get('/api/profile/pension/:id/images/stats', authenticateToken, (req, res) => { + const userId = req.user.id; + const pensionId = req.params.id; + + // 펜션 소유권 확인 + db.get(`SELECT id FROM pension_profiles WHERE id = ? AND user_id = ?`, [pensionId, userId], (err, row) => { + if (err) return res.status(500).json({ error: err.message }); + if (!row) return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); + + db.all(`SELECT source, COUNT(*) as count FROM pension_images WHERE pension_id = ? GROUP BY source`, + [pensionId], (err, stats) => { + if (err) return res.status(500).json({ error: err.message }); + + // 통계 객체로 변환 + const result = { + total: 0, + naver: 0, + google: 0, + instagram: 0, + upload: 0, + crawl: 0, // 기존 'crawl' 소스도 지원 + manual: 0 // 기존 'manual' 소스도 지원 + }; + + stats?.forEach(s => { + result[s.source] = s.count; + result.total += s.count; + }); + + res.json(result); + }); + }); +}); + +/** + * 펜션 이미지 삭제 + * DELETE /api/profile/pension/:id/images/:imageId + */ +app.delete('/api/profile/pension/:id/images/:imageId', authenticateToken, (req, res) => { + const userId = req.user.id; + const pensionId = req.params.id; + const imageId = req.params.imageId; + + // 이미지 소유권 확인 및 삭제 + db.get(`SELECT pi.id, pi.file_path FROM pension_images pi + JOIN pension_profiles pp ON pi.pension_id = pp.id + WHERE pi.id = ? AND pi.pension_id = ? AND pp.user_id = ?`, + [imageId, pensionId, userId], (err, row) => { + if (err) return res.status(500).json({ error: err.message }); + if (!row) return res.status(404).json({ error: '이미지를 찾을 수 없습니다.' }); + + // 파일 삭제 + const fullPath = path.join(__dirname, '..', row.file_path); + if (fs.existsSync(fullPath)) { + fs.unlinkSync(fullPath); + } + + // DB에서 삭제 + db.run(`DELETE FROM pension_images WHERE id = ?`, [imageId], function(err) { + if (err) return res.status(500).json({ error: err.message }); + res.json({ success: true }); + }); + }); +}); + +// ==================== AUTO GENERATION SETTINGS ==================== + +/** + * 자동 생성 설정 조회 + * GET /api/profile/pension/:id/auto-generation + */ +app.get('/api/profile/pension/:id/auto-generation', authenticateToken, (req, res) => { + const userId = req.user.id; + const pensionId = req.params.id; + + // 펜션 소유권 확인 + db.get(`SELECT id FROM pension_profiles WHERE id = ? AND user_id = ?`, [pensionId, userId], (err, pension) => { + if (err) return res.status(500).json({ error: err.message }); + if (!pension) return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); + + db.get(`SELECT * FROM daily_auto_generation WHERE pension_id = ?`, [pensionId], (err, settings) => { + if (err) return res.status(500).json({ error: err.message }); + + // 설정이 없으면 기본값 반환 + if (!settings) { + return res.json({ + pension_id: parseInt(pensionId), + enabled: false, + generation_time: '09:00', + image_mode: 'priority_first', // priority_first, random, all + random_count: 10, + auto_upload_youtube: true, + auto_upload_instagram: false, + auto_upload_tiktok: false, + last_generated_at: null, + next_scheduled_at: null + }); + } + + res.json({ + ...settings, + enabled: !!settings.enabled, + auto_upload_youtube: !!settings.auto_upload_youtube, + auto_upload_instagram: !!settings.auto_upload_instagram, + auto_upload_tiktok: !!settings.auto_upload_tiktok + }); + }); + }); +}); + +/** + * 자동 생성 설정 저장/업데이트 + * POST /api/profile/pension/:id/auto-generation + */ +app.post('/api/profile/pension/:id/auto-generation', authenticateToken, (req, res) => { + const userId = req.user.id; + const pensionId = req.params.id; + const { + enabled, + generation_time, + image_mode, + random_count, + auto_upload_youtube, + auto_upload_instagram, + auto_upload_tiktok + } = req.body; + + // 펜션 소유권 확인 + db.get(`SELECT id FROM pension_profiles WHERE id = ? AND user_id = ?`, [pensionId, userId], (err, pension) => { + if (err) return res.status(500).json({ error: err.message }); + if (!pension) return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); + + // 다음 실행 시간 계산 + const now = new Date(); + let nextScheduled = null; + if (enabled && generation_time) { + const [hours, minutes] = generation_time.split(':').map(Number); + nextScheduled = new Date(now); + nextScheduled.setHours(hours, minutes, 0, 0); + if (nextScheduled <= now) { + nextScheduled.setDate(nextScheduled.getDate() + 1); + } + } + + // UPSERT + db.run(`INSERT INTO daily_auto_generation + (pension_id, user_id, enabled, generation_time, image_mode, random_count, + auto_upload_youtube, auto_upload_instagram, auto_upload_tiktok, next_scheduled_at, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(pension_id) DO UPDATE SET + enabled = excluded.enabled, + generation_time = excluded.generation_time, + image_mode = excluded.image_mode, + random_count = excluded.random_count, + auto_upload_youtube = excluded.auto_upload_youtube, + auto_upload_instagram = excluded.auto_upload_instagram, + auto_upload_tiktok = excluded.auto_upload_tiktok, + next_scheduled_at = excluded.next_scheduled_at, + updatedAt = CURRENT_TIMESTAMP`, + [ + pensionId, userId, + enabled ? 1 : 0, + generation_time || '09:00', + image_mode || 'priority_first', + random_count || 10, + auto_upload_youtube ? 1 : 0, + auto_upload_instagram ? 1 : 0, + auto_upload_tiktok ? 1 : 0, + nextScheduled ? nextScheduled.toISOString() : null + ], + function(err) { + if (err) return res.status(500).json({ error: err.message }); + + console.log(`[AutoGen] 설정 저장: pension=${pensionId}, enabled=${enabled}, time=${generation_time}`); + res.json({ + success: true, + pension_id: parseInt(pensionId), + enabled: !!enabled, + next_scheduled_at: nextScheduled ? nextScheduled.toISOString() : null + }); + }); + }); +}); + +/** + * 자동 생성 로그 조회 + * GET /api/profile/pension/:id/auto-generation/logs + */ +app.get('/api/profile/pension/:id/auto-generation/logs', authenticateToken, (req, res) => { + const userId = req.user.id; + const pensionId = req.params.id; + const limit = parseInt(req.query.limit) || 10; + + // 펜션 소유권 확인 + db.get(`SELECT id FROM pension_profiles WHERE id = ? AND user_id = ?`, [pensionId, userId], (err, pension) => { + if (err) return res.status(500).json({ error: err.message }); + if (!pension) return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); + + db.all(`SELECT * FROM auto_generation_logs + WHERE pension_id = ? + ORDER BY createdAt DESC + LIMIT ?`, + [pensionId, limit], (err, logs) => { + if (err) return res.status(500).json({ error: err.message }); + res.json(logs || []); + }); + }); +}); + +/** + * 우선순위 기반 이미지 선택 로직 + */ +function selectImagesForAutoGeneration(pensionId, imageMode, randomCount, callback) { + // 1. 우선순위 이미지 (수동 업로드) 먼저 가져오기 + db.all(`SELECT * FROM pension_images + WHERE pension_id = ? AND is_priority = 1 + ORDER BY createdAt DESC`, + [pensionId], (err, priorityImages) => { + if (err) return callback(err); + + // 2. 일반 이미지 가져오기 (사용 횟수 적은 순, 마지막 사용 시간 오래된 순) + db.all(`SELECT * FROM pension_images + WHERE pension_id = ? AND is_priority = 0 + ORDER BY used_count ASC, last_used_at ASC NULLS FIRST, createdAt DESC`, + [pensionId], (err, normalImages) => { + if (err) return callback(err); + + let selectedImages = []; + const targetCount = randomCount || 10; + + if (imageMode === 'priority_first') { + // 우선순위 이미지 먼저, 부족하면 일반 이미지로 채움 + selectedImages = [...priorityImages]; + if (selectedImages.length < targetCount) { + const remaining = targetCount - selectedImages.length; + selectedImages = selectedImages.concat(normalImages.slice(0, remaining)); + } else { + selectedImages = selectedImages.slice(0, targetCount); + } + } else if (imageMode === 'random') { + // 전체 이미지 중 랜덤 선택 + const allImages = [...priorityImages, ...normalImages]; + selectedImages = shuffleArray(allImages).slice(0, targetCount); + } else if (imageMode === 'all') { + // 모든 이미지 사용 (최대 30개) + selectedImages = [...priorityImages, ...normalImages].slice(0, 30); + } + + callback(null, selectedImages); + }); + }); +} + +// 배열 셔플 함수 +function shuffleArray(array) { + const result = [...array]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +/** + * 이미지 사용 기록 업데이트 + */ +function updateImageUsage(imageIds) { + if (!imageIds || imageIds.length === 0) return; + + const placeholders = imageIds.map(() => '?').join(','); + db.run(`UPDATE pension_images + SET used_count = used_count + 1, last_used_at = CURRENT_TIMESTAMP + WHERE id IN (${placeholders})`, imageIds); +} + +/** + * 수동 트리거 (테스트/디버그용) + * POST /api/profile/pension/:id/auto-generation/trigger + */ +app.post('/api/profile/pension/:id/auto-generation/trigger', authenticateToken, async (req, res) => { + const userId = req.user.id; + const pensionId = req.params.id; + + // 펜션 정보 가져오기 + db.get(`SELECT pp.*, dag.image_mode, dag.random_count, dag.auto_upload_youtube, dag.auto_upload_instagram + FROM pension_profiles pp + LEFT JOIN daily_auto_generation dag ON pp.id = dag.pension_id + WHERE pp.id = ? AND pp.user_id = ?`, + [pensionId, userId], async (err, pension) => { + if (err) return res.status(500).json({ error: err.message }); + if (!pension) return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); + + const imageMode = pension.image_mode || 'priority_first'; + const randomCount = pension.random_count || 10; + + // 이미지 선택 + selectImagesForAutoGeneration(pensionId, imageMode, randomCount, async (err, images) => { + if (err) return res.status(500).json({ error: err.message }); + if (!images || images.length < 3) { + return res.status(400).json({ error: '영상 생성에 필요한 이미지가 부족합니다 (최소 3장)' }); + } + + // 로그 생성 + db.run(`INSERT INTO auto_generation_logs (pension_id, user_id, status, images_used, started_at) + VALUES (?, ?, 'processing', ?, CURRENT_TIMESTAMP)`, + [pensionId, userId, JSON.stringify(images.map(img => img.id))], + function(err) { + if (err) return res.status(500).json({ error: err.message }); + + const logId = this.lastID; + + // 이미지 URL 배열 생성 + const imageUrls = images.map(img => { + // file_path가 상대경로면 절대 URL로 변환 + if (img.file_path.startsWith('/')) { + return `${process.env.FRONTEND_URL || 'http://localhost:5173'}${img.file_path}`; + } + return img.original_url || img.file_path; + }); + + // 이미지 사용 기록 업데이트 + updateImageUsage(images.map(img => img.id)); + + res.json({ + success: true, + logId, + pension: { + id: pension.id, + name: pension.name, + business_type: pension.business_type + }, + images: imageUrls, + imageCount: images.length, + message: '자동 생성이 트리거되었습니다. 프론트엔드에서 렌더링을 진행해주세요.' + }); + }); + }); + }); +}); + +/** + * 자동 생성 로그 상태 업데이트 + * PATCH /api/auto-generation/logs/:logId + */ +app.patch('/api/auto-generation/logs/:logId', authenticateToken, (req, res) => { + const userId = req.user.id; + const logId = req.params.logId; + const { status, video_path, youtube_video_id, instagram_media_id, error_message } = req.body; + + db.run(`UPDATE auto_generation_logs + SET status = ?, video_path = ?, youtube_video_id = ?, instagram_media_id = ?, + error_message = ?, completed_at = CASE WHEN ? IN ('completed', 'failed') THEN CURRENT_TIMESTAMP ELSE completed_at END + WHERE id = ? AND user_id = ?`, + [status, video_path, youtube_video_id, instagram_media_id, error_message, status, logId, userId], + function(err) { + if (err) return res.status(500).json({ error: err.message }); + if (this.changes === 0) return res.status(404).json({ error: '로그를 찾을 수 없습니다.' }); + + // 성공 시 다음 스케줄 업데이트 + if (status === 'completed') { + db.get(`SELECT pension_id FROM auto_generation_logs WHERE id = ?`, [logId], (err, log) => { + if (!err && log) { + updateNextSchedule(log.pension_id); + } + }); + } + + res.json({ success: true }); + }); +}); + +/** + * 다음 스케줄 시간 업데이트 + */ +function updateNextSchedule(pensionId) { + db.get(`SELECT generation_time, enabled FROM daily_auto_generation WHERE pension_id = ?`, + [pensionId], (err, settings) => { + if (err || !settings || !settings.enabled) return; + + const [hours, minutes] = settings.generation_time.split(':').map(Number); + const nextScheduled = new Date(); + nextScheduled.setDate(nextScheduled.getDate() + 1); + nextScheduled.setHours(hours, minutes, 0, 0); + + db.run(`UPDATE daily_auto_generation + SET last_generated_at = CURRENT_TIMESTAMP, + next_scheduled_at = ?, + consecutive_failures = 0 + WHERE pension_id = ?`, + [nextScheduled.toISOString(), pensionId]); + }); +} + +/** + * 전체 예약된 자동 생성 목록 (스케줄러용) + * GET /api/auto-generation/scheduled + */ +app.get('/api/auto-generation/scheduled', (req, res) => { + // 현재 시간 기준으로 실행해야 할 작업 조회 + const now = new Date().toISOString(); + + db.all(`SELECT dag.*, pp.name as pension_name, pp.business_type, u.email as user_email + FROM daily_auto_generation dag + JOIN pension_profiles pp ON dag.pension_id = pp.id + JOIN users u ON dag.user_id = u.id + WHERE dag.enabled = 1 + AND (dag.next_scheduled_at IS NULL OR dag.next_scheduled_at <= ?) + AND dag.consecutive_failures < 3`, + [now], (err, jobs) => { + if (err) return res.status(500).json({ error: err.message }); + res.json(jobs || []); + }); +}); + // ==================== USER ASSETS MANAGEMENT ==================== // 사용자별 에셋 디렉토리 생성 함수 @@ -2759,6 +4015,94 @@ app.get('/api/youtube/upload-history', authenticateToken, async (req, res) => { } }); +// ==================== AI AUTO DESCRIPTION GENERATION ==================== + +/** + * AI 매직 라이트 - 펜션 컨셉 자동 생성 + * POST /api/ai/auto-description + */ +app.post('/api/ai/auto-description', authenticateToken, async (req, res) => { + try { + const { + name, + address, + pensionCategories, + selectedFestivals, + existingDescription, + language + } = req.body; + + if (!name) { + return res.status(400).json({ error: '펜션 이름이 필요합니다.' }); + } + + // 카테고리 한글 매핑 + const categoryMap = { + 'PoolVilla': '풀빌라', + 'OceanView': '오션뷰', + 'Mountain': '산/계곡', + 'Glamping': '글램핑', + 'Couple': '커플', + 'Family': '가족', + 'Pet': '반려동물 동반', + 'Traditional': '전통한옥' + }; + + const categoryNames = (pensionCategories || []).map(c => categoryMap[c] || c).join(', '); + + // 축제 정보 정리 + let festivalInfo = ''; + if (selectedFestivals && selectedFestivals.length > 0) { + const festivalNames = selectedFestivals.map(f => f.title).join(', '); + const festivalDates = selectedFestivals.map(f => { + const start = f.eventstartdate || f.event_start_date || ''; + const end = f.eventenddate || f.event_end_date || ''; + if (start && end) { + return `${f.title} (${start.slice(4,6)}/${start.slice(6,8)}~${end.slice(4,6)}/${end.slice(6,8)})`; + } + return f.title; + }).join(', '); + festivalInfo = `\n- 근처 축제: ${festivalDates}`; + } + + // Gemini 프롬프트 생성 + const prompt = `당신은 펜션/숙소 마케팅 전문가입니다. 아래 정보를 바탕으로 펜션의 매력적인 컨셉 설명을 작성해주세요. + +## 펜션 정보 +- 이름: ${name} +- 위치: ${address || '정보 없음'} +- 유형: ${categoryNames || '정보 없음'}${festivalInfo} +${existingDescription ? `- 기존 설명 참고: ${existingDescription}` : ''} + +## 작성 가이드라인 +1. 2-3문장으로 간결하게 작성 +2. 펜션의 핵심 매력 포인트를 강조 +3. 타겟 고객층(커플, 가족, 반려동물 동반 등)에 맞는 어필 포인트 포함 +4. 계절감이나 축제 정보가 있다면 자연스럽게 연결 +5. 감성적이고 매력적인 문체 사용 +6. 예약을 유도하는 호기심 유발 표현 사용 + +컨셉 설명만 작성하세요. 다른 설명이나 부연 없이 설명문만 출력하세요.`; + + // Gemini API 호출 + const { GoogleGenerativeAI } = require('@google-cloud/generative-ai') || require('@google/generative-ai'); + const genAI = new GoogleGenerativeAI(process.env.VITE_GEMINI_API_KEY || process.env.GEMINI_API_KEY); + const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash' }); + + const result = await model.generateContent(prompt); + const description = result.response.text().trim(); + + res.json({ + description, + success: true + }); + + } catch (error) { + console.error('[AI Auto Description] 오류:', error.message); + res.status(500).json({ error: 'AI 컨셉 생성 실패', details: error.message }); + } +}); + // ==================== YOUTUBE API ROUTES (Legacy) ==================== /** @@ -4180,6 +5524,172 @@ app.get('/api/user/plan', authenticateToken, (req, res) => { }); }); +/** + * 사용자 경험 레벨 조회 + * GET /api/user/level + */ +app.get('/api/user/level', authenticateToken, (req, res) => { + const userId = req.user.id; + db.get(`SELECT experience_level FROM users WHERE id = ?`, [userId], (err, row) => { + if (err) return res.status(500).json({ error: err.message }); + res.json({ level: row?.experience_level || 'beginner' }); + }); +}); + +/** + * 사용자 경험 레벨 변경 + * PUT /api/user/level + */ +app.put('/api/user/level', authenticateToken, (req, res) => { + const userId = req.user.id; + const { level } = req.body; + + if (!['beginner', 'intermediate', 'pro'].includes(level)) { + return res.status(400).json({ error: '유효하지 않은 레벨입니다.' }); + } + + db.run(`UPDATE users SET experience_level = ? WHERE id = ?`, [level, userId], function(err) { + if (err) return res.status(500).json({ error: err.message }); + res.json({ success: true, level }); + }); +}); + +/** + * 자동 생성 설정 조회 + * GET /api/user/auto-generation + */ +app.get('/api/user/auto-generation', authenticateToken, (req, res) => { + const userId = req.user.id; + db.get(`SELECT * FROM auto_generation_settings WHERE user_id = ?`, [userId], (err, row) => { + if (err) return res.status(500).json({ error: err.message }); + if (!row) { + return res.json({ + enabled: false, + day_of_week: 1, + time_of_day: '09:00', + pension_id: null, + last_generated_at: null, + next_scheduled_at: null + }); + } + res.json(row); + }); +}); + +/** + * 자동 생성 설정 저장 + * PUT /api/user/auto-generation + */ +app.put('/api/user/auto-generation', authenticateToken, (req, res) => { + const userId = req.user.id; + const { enabled, day_of_week, time_of_day, pension_id } = req.body; + + // 다음 예약 시간 계산 + const calculateNextScheduled = () => { + if (!enabled) return null; + const now = new Date(); + const [hours, minutes] = (time_of_day || '09:00').split(':').map(Number); + const targetDay = day_of_week ?? 1; + + let next = new Date(now); + next.setHours(hours, minutes, 0, 0); + + // 다음 요일로 설정 + const daysUntilTarget = (targetDay - now.getDay() + 7) % 7; + if (daysUntilTarget === 0 && next <= now) { + next.setDate(next.getDate() + 7); + } else { + next.setDate(next.getDate() + daysUntilTarget); + } + return next.toISOString(); + }; + + const nextScheduled = calculateNextScheduled(); + + db.run(` + INSERT INTO auto_generation_settings (user_id, enabled, day_of_week, time_of_day, pension_id, next_scheduled_at, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET + enabled = excluded.enabled, + day_of_week = excluded.day_of_week, + time_of_day = excluded.time_of_day, + pension_id = excluded.pension_id, + next_scheduled_at = excluded.next_scheduled_at, + updatedAt = CURRENT_TIMESTAMP + `, [userId, enabled ? 1 : 0, day_of_week ?? 1, time_of_day ?? '09:00', pension_id, nextScheduled], function(err) { + if (err) return res.status(500).json({ error: err.message }); + res.json({ success: true, next_scheduled_at: nextScheduled }); + }); +}); + +/** + * 자동 업로드 실행 + * POST /api/auto-upload + */ +app.post('/api/auto-upload', authenticateToken, async (req, res) => { + const userId = req.user.id; + const { videoPath, historyId, businessInfo, language } = req.body; + + try { + const autoUploadService = require('./services/autoUploadService'); + + // 자동 업로드 실행 + const result = await autoUploadService.executeAutoUpload( + userId, + videoPath, + businessInfo, + language || 'KO' + ); + + // 업로드 이력 저장 + if (historyId && result.results) { + for (const [platform, platformResult] of Object.entries(result.results)) { + await autoUploadService.saveUploadHistory(userId, historyId, platform, platformResult); + } + } + + res.json(result); + } catch (error) { + console.error('자동 업로드 실패:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 자동 업로드 설정 조회/수정 (Pro 사용자용) + * GET/PUT /api/user/auto-upload-settings + */ +app.get('/api/user/auto-upload-settings', authenticateToken, (req, res) => { + const userId = req.user.id; + + db.get(` + SELECT auto_youtube, auto_instagram, auto_tiktok + FROM auto_generation_settings + WHERE user_id = ? + `, [userId], (err, row) => { + if (err) return res.status(500).json({ error: err.message }); + res.json(row || { auto_youtube: 1, auto_instagram: 1, auto_tiktok: 1 }); + }); +}); + +app.put('/api/user/auto-upload-settings', authenticateToken, (req, res) => { + const userId = req.user.id; + const { auto_youtube, auto_instagram, auto_tiktok } = req.body; + + db.run(` + INSERT INTO auto_generation_settings (user_id, auto_youtube, auto_instagram, auto_tiktok) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + auto_youtube = excluded.auto_youtube, + auto_instagram = excluded.auto_instagram, + auto_tiktok = excluded.auto_tiktok, + updatedAt = CURRENT_TIMESTAMP + `, [userId, auto_youtube ? 1 : 0, auto_instagram ? 1 : 0, auto_tiktok ? 1 : 0], function(err) { + if (err) return res.status(500).json({ error: err.message }); + res.json({ success: true }); + }); +}); + /** * 펜션에 YouTube 플레이리스트 연결 * POST /api/profile/pension/:id/youtube-playlist @@ -5012,6 +6522,1208 @@ app.get('/api/admin/analytics/full-report', authenticateToken, requireAdmin, asy // ==================== END ADVANCED STATISTICS ROUTES ==================== +// ==================== API USAGE MONITORING ROUTES ==================== + +/** + * API 사용량 통계 조회 + * GET /api/admin/api-usage/stats + * Query: { days?: number, service?: string } + */ +app.get('/api/admin/api-usage/stats', authenticateToken, requireAdmin, async (req, res) => { + try { + const days = parseInt(req.query.days) || 30; + const service = req.query.service || null; + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + // 서비스별 요약 통계 + const summaryQuery = ` + SELECT + service, + model, + COUNT(*) as total_calls, + SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count, + SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error_count, + SUM(tokens_input) as total_tokens_input, + SUM(tokens_output) as total_tokens_output, + SUM(image_count) as total_images, + SUM(audio_seconds) as total_audio_seconds, + SUM(video_seconds) as total_video_seconds, + SUM(cost_estimate) as total_cost, + AVG(latency_ms) as avg_latency + FROM api_usage_logs + WHERE createdAt >= ? + ${service ? 'AND service = ?' : ''} + GROUP BY service, model + ORDER BY total_calls DESC + `; + + const params = [startDate.toISOString()]; + if (service) params.push(service); + + const summary = await new Promise((resolve, reject) => { + db.all(summaryQuery, params, (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }); + + // 일별 추이 + const dailyQuery = ` + SELECT + DATE(createdAt) as date, + service, + COUNT(*) as calls, + SUM(cost_estimate) as cost + FROM api_usage_logs + WHERE createdAt >= ? + ${service ? 'AND service = ?' : ''} + GROUP BY DATE(createdAt), service + ORDER BY date DESC + `; + + const daily = await new Promise((resolve, reject) => { + db.all(dailyQuery, params, (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }); + + // 최근 에러 목록 + const errorsQuery = ` + SELECT + id, service, model, endpoint, error_message, latency_ms, createdAt + FROM api_usage_logs + WHERE status = 'error' + AND createdAt >= ? + ${service ? 'AND service = ?' : ''} + ORDER BY createdAt DESC + LIMIT 20 + `; + + const recentErrors = await new Promise((resolve, reject) => { + db.all(errorsQuery, params, (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }); + + // 총 비용 계산 + const totalCost = summary.reduce((sum, s) => sum + (s.total_cost || 0), 0); + const totalCalls = summary.reduce((sum, s) => sum + (s.total_calls || 0), 0); + const totalErrors = summary.reduce((sum, s) => sum + (s.error_count || 0), 0); + const avgLatency = summary.length > 0 + ? summary.reduce((sum, s) => sum + (s.avg_latency || 0), 0) / summary.length + : 0; + + res.json({ + period: `${days}일`, + overview: { + totalCalls, + totalErrors, + errorRate: totalCalls > 0 ? ((totalErrors / totalCalls) * 100).toFixed(2) : 0, + totalCostUSD: totalCost.toFixed(4), + avgLatencyMs: Math.round(avgLatency) + }, + byServiceModel: summary, + dailyTrend: daily, + recentErrors, + links: { + googleAiStudio: 'https://aistudio.google.com/app/apikey', + googleCloudBilling: 'https://console.cloud.google.com/billing' + } + }); + } catch (error) { + console.error('[API Usage Stats] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * API 사용량 상세 로그 조회 + * GET /api/admin/api-usage/logs + * Query: { page?, limit?, service?, status? } + */ +app.get('/api/admin/api-usage/logs', authenticateToken, requireAdmin, async (req, res) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 50; + const offset = (page - 1) * limit; + const { service, status, model } = req.query; + + let whereClause = '1=1'; + const params = []; + + if (service) { + whereClause += ' AND service = ?'; + params.push(service); + } + if (status) { + whereClause += ' AND status = ?'; + params.push(status); + } + if (model) { + whereClause += ' AND model LIKE ?'; + params.push(`%${model}%`); + } + + // 총 개수 + const countResult = await new Promise((resolve, reject) => { + db.get( + `SELECT COUNT(*) as total FROM api_usage_logs WHERE ${whereClause}`, + params, + (err, row) => { + if (err) reject(err); + else resolve(row?.total || 0); + } + ); + }); + + // 로그 조회 + const logs = await new Promise((resolve, reject) => { + db.all( + `SELECT + l.*, + u.username + FROM api_usage_logs l + LEFT JOIN users u ON l.user_id = u.id + WHERE ${whereClause} + ORDER BY l.createdAt DESC + LIMIT ? OFFSET ?`, + [...params, limit, offset], + (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + } + ); + }); + + res.json({ + logs, + pagination: { + page, + limit, + total: countResult, + totalPages: Math.ceil(countResult / limit) + } + }); + } catch (error) { + console.error('[API Usage Logs] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 모델별 우선순위 설정 조회 + * GET /api/admin/api-usage/models + */ +app.get('/api/admin/api-usage/models', authenticateToken, requireAdmin, async (req, res) => { + try { + // 현재 모델 설정 (geminiBackendService에서 정의된 것) + const imageModels = [ + { id: 'gemini-2.0-flash-preview-image-generation', name: 'Gemini 2.0 Flash Image (Preview)', priority: 1, costPerImage: 0.02 }, + { id: 'gemini-2.5-flash-image', name: 'Gemini 2.5 Flash Image', priority: 2, costPerImage: 0.015 }, + { id: 'imagen-3.0-generate-002', name: 'Imagen 3', priority: 3, costPerImage: 0.03 } + ]; + + const textModels = [ + { id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', type: 'text', inputCostPer1M: 0.10, outputCostPer1M: 0.40 }, + { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', type: 'text', inputCostPer1M: 0.075, outputCostPer1M: 0.30 }, + { id: 'gemini-2.5-flash-preview-tts', name: 'Gemini TTS', type: 'audio', costPerMinute: 0.001 } + ]; + + const videoModels = [ + { id: 'veo-3.1-fast-generate-preview', name: 'Veo 3.1 Fast', type: 'video', costPerSecond: 0.05 } + ]; + + res.json({ + imageModels, + textModels, + videoModels, + note: '모델 우선순위는 순차적으로 시도되며, 실패 시 다음 모델로 자동 폴백됩니다.' + }); + } catch (error) { + console.error('[API Models] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 사용자별 API 사용량 통계 (과금용) + * GET /api/admin/api-usage/by-user + * Query: { days?: number, limit?: number } + */ +app.get('/api/admin/api-usage/by-user', authenticateToken, requireAdmin, async (req, res) => { + try { + const days = parseInt(req.query.days) || 30; + const limit = parseInt(req.query.limit) || 50; + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + // 사용자별 사용량 집계 + const userUsageQuery = ` + SELECT + l.user_id, + u.username, + u.name, + u.email, + u.plan_type, + COUNT(*) as total_calls, + SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END) as success_count, + SUM(CASE WHEN l.status = 'error' THEN 1 ELSE 0 END) as error_count, + SUM(l.tokens_input) as total_tokens_input, + SUM(l.tokens_output) as total_tokens_output, + SUM(l.image_count) as total_images, + SUM(l.audio_seconds) as total_audio_seconds, + SUM(l.video_seconds) as total_video_seconds, + SUM(l.cost_estimate) as total_cost_usd, + AVG(l.latency_ms) as avg_latency + FROM api_usage_logs l + LEFT JOIN users u ON l.user_id = u.id + WHERE l.createdAt >= ? + AND l.user_id IS NOT NULL + GROUP BY l.user_id + ORDER BY total_cost_usd DESC + LIMIT ? + `; + + const userUsage = await new Promise((resolve, reject) => { + db.all(userUsageQuery, [startDate.toISOString(), limit], (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }); + + // 총 비용 계산 + const totalCost = userUsage.reduce((sum, u) => sum + (u.total_cost_usd || 0), 0); + + // KRW 환산 (대략적인 환율) + const usdToKrw = 1350; + + res.json({ + period: `${days}일`, + totalUsers: userUsage.length, + totalCostUSD: totalCost.toFixed(4), + totalCostKRW: Math.round(totalCost * usdToKrw), + users: userUsage.map(u => ({ + userId: u.user_id, + username: u.username || 'Unknown', + name: u.name || '', + email: u.email || '', + planType: u.plan_type || 'free', + totalCalls: u.total_calls, + successCount: u.success_count, + errorCount: u.error_count, + totalImages: u.total_images || 0, + totalAudioSeconds: Math.round(u.total_audio_seconds || 0), + totalVideoSeconds: Math.round(u.total_video_seconds || 0), + tokensInput: u.total_tokens_input || 0, + tokensOutput: u.total_tokens_output || 0, + costUSD: parseFloat((u.total_cost_usd || 0).toFixed(4)), + costKRW: Math.round((u.total_cost_usd || 0) * usdToKrw), + avgLatencyMs: Math.round(u.avg_latency || 0) + })), + exchangeRate: { USD_KRW: usdToKrw, note: '예상 환율' } + }); + } catch (error) { + console.error('[API Usage By User] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 특정 사용자의 API 사용량 상세 + * GET /api/admin/api-usage/user/:userId + * Query: { days?: number } + */ +app.get('/api/admin/api-usage/user/:userId', authenticateToken, requireAdmin, async (req, res) => { + try { + const { userId } = req.params; + const days = parseInt(req.query.days) || 30; + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + // 사용자 정보 + const user = await new Promise((resolve, reject) => { + db.get('SELECT id, username, name, email, plan_type, credits FROM users WHERE id = ?', [userId], (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); + + if (!user) { + return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' }); + } + + // 서비스/모델별 사용량 + const usageByModel = await new Promise((resolve, reject) => { + db.all(` + SELECT + service, + model, + COUNT(*) as calls, + SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success, + SUM(image_count) as images, + SUM(audio_seconds) as audio_sec, + SUM(video_seconds) as video_sec, + SUM(cost_estimate) as cost + FROM api_usage_logs + WHERE user_id = ? AND createdAt >= ? + GROUP BY service, model + ORDER BY cost DESC + `, [userId, startDate.toISOString()], (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }); + + // 일별 추이 + const dailyTrend = await new Promise((resolve, reject) => { + db.all(` + SELECT + DATE(createdAt) as date, + COUNT(*) as calls, + SUM(cost_estimate) as cost + FROM api_usage_logs + WHERE user_id = ? AND createdAt >= ? + GROUP BY DATE(createdAt) + ORDER BY date DESC + `, [userId, startDate.toISOString()], (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }); + + // 최근 호출 내역 + const recentCalls = await new Promise((resolve, reject) => { + db.all(` + SELECT + id, service, model, endpoint, status, error_message, + image_count, audio_seconds, video_seconds, + cost_estimate, latency_ms, createdAt + FROM api_usage_logs + WHERE user_id = ? + ORDER BY createdAt DESC + LIMIT 50 + `, [userId], (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }); + + // 총 비용 + const totalCost = usageByModel.reduce((sum, m) => sum + (m.cost || 0), 0); + const usdToKrw = 1350; + + res.json({ + user: { + id: user.id, + username: user.username, + name: user.name, + email: user.email, + planType: user.plan_type, + credits: user.credits + }, + period: `${days}일`, + summary: { + totalCostUSD: totalCost.toFixed(4), + totalCostKRW: Math.round(totalCost * usdToKrw), + totalCalls: usageByModel.reduce((sum, m) => sum + m.calls, 0), + totalImages: usageByModel.reduce((sum, m) => sum + (m.images || 0), 0), + totalAudioMinutes: Math.round(usageByModel.reduce((sum, m) => sum + (m.audio_sec || 0), 0) / 60), + totalVideoSeconds: Math.round(usageByModel.reduce((sum, m) => sum + (m.video_sec || 0), 0)) + }, + usageByModel, + dailyTrend, + recentCalls + }); + } catch (error) { + console.error('[API Usage User Detail] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 월별 비용 리포트 (과금용) + * GET /api/admin/api-usage/monthly-report + * Query: { year?: number, month?: number } + */ +app.get('/api/admin/api-usage/monthly-report', authenticateToken, requireAdmin, async (req, res) => { + try { + const now = new Date(); + const year = parseInt(req.query.year) || now.getFullYear(); + const month = parseInt(req.query.month) || (now.getMonth() + 1); + + const startDate = new Date(year, month - 1, 1); + const endDate = new Date(year, month, 0, 23, 59, 59); + + // 사용자별 월간 비용 + const monthlyByUser = await new Promise((resolve, reject) => { + db.all(` + SELECT + l.user_id, + u.username, + u.name, + u.email, + u.plan_type, + COUNT(*) as total_calls, + SUM(l.image_count) as total_images, + SUM(l.audio_seconds) as total_audio_seconds, + SUM(l.video_seconds) as total_video_seconds, + SUM(l.cost_estimate) as total_cost + FROM api_usage_logs l + LEFT JOIN users u ON l.user_id = u.id + WHERE l.createdAt >= ? AND l.createdAt <= ? + AND l.user_id IS NOT NULL + GROUP BY l.user_id + ORDER BY total_cost DESC + `, [startDate.toISOString(), endDate.toISOString()], (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }); + + // 서비스별 총 비용 + const byService = await new Promise((resolve, reject) => { + db.all(` + SELECT + service, + COUNT(*) as calls, + SUM(cost_estimate) as cost + FROM api_usage_logs + WHERE createdAt >= ? AND createdAt <= ? + GROUP BY service + `, [startDate.toISOString(), endDate.toISOString()], (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }); + + const totalCost = monthlyByUser.reduce((sum, u) => sum + (u.total_cost || 0), 0); + const usdToKrw = 1350; + + res.json({ + period: `${year}년 ${month}월`, + year, + month, + summary: { + totalUsers: monthlyByUser.length, + totalCalls: monthlyByUser.reduce((sum, u) => sum + u.total_calls, 0), + totalCostUSD: totalCost.toFixed(2), + totalCostKRW: Math.round(totalCost * usdToKrw) + }, + byService: byService.map(s => ({ + service: s.service, + calls: s.calls, + costUSD: parseFloat((s.cost || 0).toFixed(4)) + })), + byUser: monthlyByUser.map(u => ({ + userId: u.user_id, + username: u.username, + name: u.name, + email: u.email, + planType: u.plan_type, + calls: u.total_calls, + images: u.total_images || 0, + audioMinutes: Math.round((u.total_audio_seconds || 0) / 60), + videoSeconds: Math.round(u.total_video_seconds || 0), + costUSD: parseFloat((u.total_cost || 0).toFixed(4)), + costKRW: Math.round((u.total_cost || 0) * usdToKrw) + })), + exchangeRate: { USD_KRW: usdToKrw } + }); + } catch (error) { + console.error('[Monthly Report] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +// ==================== END API USAGE MONITORING ROUTES ==================== + + +// ==================== GOOGLE CLOUD BILLING ROUTES (BigQuery) ==================== + +const billingService = require('./billingService'); + +/** + * 결제 데이터 사용 가능 여부 확인 + * GET /api/admin/billing/status + */ +app.get('/api/admin/billing/status', authenticateToken, requireAdmin, async (req, res) => { + try { + const status = await billingService.checkBillingDataAvailable(); + res.json(status); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * 전체 결제 대시보드 데이터 + * GET /api/admin/billing/dashboard + * Query: { days?: number } + */ +app.get('/api/admin/billing/dashboard', authenticateToken, requireAdmin, async (req, res) => { + try { + const days = parseInt(req.query.days) || 30; + const data = await billingService.getBillingDashboard(days); + res.json(data); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * 서비스별 비용 + * GET /api/admin/billing/by-service + * Query: { days?: number } + */ +app.get('/api/admin/billing/by-service', authenticateToken, requireAdmin, async (req, res) => { + try { + const days = parseInt(req.query.days) || 30; + const data = await billingService.getCostByService(days); + res.json(data); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * 일별 비용 추이 + * GET /api/admin/billing/daily + * Query: { days?: number } + */ +app.get('/api/admin/billing/daily', authenticateToken, requireAdmin, async (req, res) => { + try { + const days = parseInt(req.query.days) || 30; + const data = await billingService.getDailyCostTrend(days); + res.json(data); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * SKU별 상세 비용 + * GET /api/admin/billing/by-sku + * Query: { days?: number, service?: string } + */ +app.get('/api/admin/billing/by-sku', authenticateToken, requireAdmin, async (req, res) => { + try { + const days = parseInt(req.query.days) || 30; + const service = req.query.service || null; + const data = await billingService.getCostBySKU(days, service); + res.json(data); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * 월별 비용 요약 + * GET /api/admin/billing/monthly + * Query: { months?: number } + */ +app.get('/api/admin/billing/monthly', authenticateToken, requireAdmin, async (req, res) => { + try { + const months = parseInt(req.query.months) || 6; + const data = await billingService.getMonthlyCost(months); + res.json(data); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * Gemini/Vertex AI 비용 + * GET /api/admin/billing/gemini + * Query: { days?: number } + */ +app.get('/api/admin/billing/gemini', authenticateToken, requireAdmin, async (req, res) => { + try { + const days = parseInt(req.query.days) || 30; + const data = await billingService.getGeminiCost(days); + res.json(data); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ==================== END GOOGLE CLOUD BILLING ROUTES ==================== + + +// ==================== FESTIVAL & PENSION API ROUTES ==================== + +/** + * 축제 목록 조회 + * GET /api/festivals + * Query: { sido?, sigungu?, startDate?, endDate?, keyword?, page?, limit? } + */ +app.get('/api/festivals', async (req, res) => { + try { + const { + sido, + sigungu, + startDate, + endDate, + keyword, + page = 1, + limit = 20 + } = req.query; + + let sql = ` + SELECT * FROM festivals + WHERE is_active = 1 + `; + const params = []; + + if (sido) { + sql += ` AND sido = ?`; + params.push(sido); + } + + if (sigungu) { + sql += ` AND sigungu = ?`; + params.push(sigungu); + } + + if (startDate) { + sql += ` AND event_end_date >= ?`; + params.push(startDate); + } + + if (endDate) { + sql += ` AND event_start_date <= ?`; + params.push(endDate); + } + + if (keyword) { + sql += ` AND (title LIKE ? OR addr1 LIKE ?)`; + params.push(`%${keyword}%`, `%${keyword}%`); + } + + // 총 개수 조회 + const countSql = sql.replace('SELECT *', 'SELECT COUNT(*) as total'); + + sql += ` ORDER BY event_start_date ASC LIMIT ? OFFSET ?`; + const offset = (parseInt(page) - 1) * parseInt(limit); + params.push(parseInt(limit), offset); + + const [festivals, countResult] = await Promise.all([ + new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => err ? reject(err) : resolve(rows)); + }), + new Promise((resolve, reject) => { + db.get(countSql, params.slice(0, -2), (err, row) => err ? reject(err) : resolve(row)); + }) + ]); + + res.json({ + festivals, + pagination: { + total: countResult?.total || 0, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil((countResult?.total || 0) / parseInt(limit)) + } + }); + } catch (error) { + console.error('[Festivals List] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 축제 행정구역별 그룹 조회 + * GET /api/festivals/grouped + * Query: { primarySido?, limit? } + * Returns festivals grouped by sido, with primarySido first + */ +app.get('/api/festivals/grouped', async (req, res) => { + try { + const { primarySido, limit = 30 } = req.query; + + // Get current date for filtering ongoing/upcoming festivals + const today = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + + // Fetch active festivals that are ongoing or upcoming + const sql = ` + SELECT * FROM festivals + WHERE is_active = 1 + AND event_end_date >= ? + ORDER BY + CASE WHEN sido = ? THEN 0 ELSE 1 END, + event_start_date ASC + LIMIT ? + `; + + const festivals = await new Promise((resolve, reject) => { + db.all(sql, [today, primarySido || '', parseInt(limit)], (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); + + // Group by sido + const grouped = {}; + const sidoOrder = []; + + festivals.forEach(festival => { + const sido = festival.sido || '기타'; + if (!grouped[sido]) { + grouped[sido] = []; + sidoOrder.push(sido); + } + grouped[sido].push(festival); + }); + + // Ensure primarySido is first if it exists + if (primarySido && grouped[primarySido]) { + const idx = sidoOrder.indexOf(primarySido); + if (idx > 0) { + sidoOrder.splice(idx, 1); + sidoOrder.unshift(primarySido); + } + } + + res.json({ + grouped, + sidoOrder, + primarySido: primarySido || null, + totalCount: festivals.length + }); + } catch (error) { + console.error('[Festivals Grouped] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 축제 상세 조회 + * GET /api/festivals/:id + */ +app.get('/api/festivals/:id', async (req, res) => { + try { + const { id } = req.params; + + const festival = await new Promise((resolve, reject) => { + db.get('SELECT * FROM festivals WHERE id = ?', [id], (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); + + if (!festival) { + return res.status(404).json({ error: '축제를 찾을 수 없습니다.' }); + } + + // 조회수 증가 + db.run('UPDATE festivals SET view_count = view_count + 1 WHERE id = ?', [id]); + + res.json(festival); + } catch (error) { + console.error('[Festival Detail] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 축제 근처 펜션 조회 + * GET /api/festivals/:id/nearby-pensions + * Query: { radius?: number (km), limit?: number } + */ +app.get('/api/festivals/:id/nearby-pensions', async (req, res) => { + try { + const { id } = req.params; + const radius = parseFloat(req.query.radius) || 30; + const limit = parseInt(req.query.limit) || 20; + + // 축제 좌표 조회 + const festival = await new Promise((resolve, reject) => { + db.get('SELECT mapx, mapy, sido FROM festivals WHERE id = ?', [id], (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); + + if (!festival || !festival.mapx || !festival.mapy) { + return res.status(404).json({ error: '축제 좌표 정보가 없습니다.' }); + } + + // Haversine 공식을 사용한 거리 계산 (SQLite에서 직접 계산) + const sql = ` + SELECT *, + (6371 * acos(cos(radians(?)) * cos(radians(mapy)) * cos(radians(mapx) - radians(?)) + sin(radians(?)) * sin(radians(mapy)))) AS distance_km + FROM public_pensions + WHERE mapx IS NOT NULL AND mapy IS NOT NULL + HAVING distance_km <= ? + ORDER BY distance_km ASC + LIMIT ? + `; + + const pensions = await new Promise((resolve, reject) => { + db.all(sql, [festival.mapy, festival.mapx, festival.mapy, radius, limit], (err, rows) => { + if (err) { + // SQLite doesn't have radians/acos, fall back to simple query + const fallbackSql = ` + SELECT * FROM public_pensions + WHERE sido = ? + ORDER BY name ASC + LIMIT ? + `; + db.all(fallbackSql, [festival.sido, limit], (err2, rows2) => { + if (err2) reject(err2); + else resolve(rows2); + }); + } else { + resolve(rows); + } + }); + }); + + res.json({ + festivalId: id, + radius, + pensions, + total: pensions.length + }); + } catch (error) { + console.error('[Nearby Pensions] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 펜션 목록 조회 + * GET /api/pensions + * Query: { sido?, sigungu?, keyword?, page?, limit? } + */ +app.get('/api/pensions', async (req, res) => { + try { + const { + sido, + sigungu, + keyword, + page = 1, + limit = 20 + } = req.query; + + let sql = `SELECT * FROM public_pensions WHERE 1=1`; + const params = []; + + if (sido) { + sql += ` AND sido = ?`; + params.push(sido); + } + + if (sigungu) { + sql += ` AND sigungu = ?`; + params.push(sigungu); + } + + if (keyword) { + sql += ` AND (name LIKE ? OR address LIKE ?)`; + params.push(`%${keyword}%`, `%${keyword}%`); + } + + // 총 개수 조회 + const countSql = sql.replace('SELECT *', 'SELECT COUNT(*) as total'); + + sql += ` ORDER BY sido, name ASC LIMIT ? OFFSET ?`; + const offset = (parseInt(page) - 1) * parseInt(limit); + params.push(parseInt(limit), offset); + + const [pensions, countResult] = await Promise.all([ + new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => err ? reject(err) : resolve(rows)); + }), + new Promise((resolve, reject) => { + db.get(countSql, params.slice(0, -2), (err, row) => err ? reject(err) : resolve(row)); + }) + ]); + + res.json({ + pensions, + pagination: { + total: countResult?.total || 0, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil((countResult?.total || 0) / parseInt(limit)) + } + }); + } catch (error) { + console.error('[Pensions List] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 펜션 상세 조회 + * GET /api/pensions/:id + */ +app.get('/api/pensions/:id', async (req, res) => { + try { + const { id } = req.params; + + const pension = await new Promise((resolve, reject) => { + db.get('SELECT * FROM public_pensions WHERE id = ?', [id], (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); + + if (!pension) { + return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); + } + + // 조회수 증가 + db.run('UPDATE public_pensions SET view_count = view_count + 1 WHERE id = ?', [id]); + + res.json(pension); + } catch (error) { + console.error('[Pension Detail] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 펜션 근처 축제 조회 + * GET /api/pensions/:id/nearby-festivals + * Query: { radius?: number (km), limit?: number } + */ +app.get('/api/pensions/:id/nearby-festivals', async (req, res) => { + try { + const { id } = req.params; + const radius = parseFloat(req.query.radius) || 50; + const limit = parseInt(req.query.limit) || 10; + + // 펜션 좌표 조회 + const pension = await new Promise((resolve, reject) => { + db.get('SELECT mapx, mapy, sido FROM public_pensions WHERE id = ?', [id], (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); + + if (!pension || !pension.mapx || !pension.mapy) { + return res.status(404).json({ error: '펜션 좌표 정보가 없습니다.' }); + } + + // 같은 지역의 진행중/예정 축제 조회 + const today = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const sql = ` + SELECT * FROM festivals + WHERE sido = ? + AND is_active = 1 + AND event_end_date >= ? + ORDER BY event_start_date ASC + LIMIT ? + `; + + const festivals = await new Promise((resolve, reject) => { + db.all(sql, [pension.sido, today, limit], (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); + + res.json({ + pensionId: id, + radius, + festivals, + total: festivals.length + }); + } catch (error) { + console.error('[Nearby Festivals] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 지역별 펜션 통계 + * GET /api/pensions/stats/by-region + */ +app.get('/api/pensions/stats/by-region', async (req, res) => { + try { + const stats = await new Promise((resolve, reject) => { + db.all(` + SELECT sido, COUNT(*) as count + FROM public_pensions + WHERE sido IS NOT NULL + GROUP BY sido + ORDER BY count DESC + `, [], (err, rows) => err ? reject(err) : resolve(rows)); + }); + + const total = stats.reduce((sum, row) => sum + row.count, 0); + + res.json({ stats, total }); + } catch (error) { + console.error('[Pension Stats] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 지역별 축제 통계 + * GET /api/festivals/stats/by-region + */ +app.get('/api/festivals/stats/by-region', async (req, res) => { + try { + const today = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + + const stats = await new Promise((resolve, reject) => { + db.all(` + SELECT sido, COUNT(*) as count + FROM festivals + WHERE sido IS NOT NULL + AND is_active = 1 + AND event_end_date >= ? + GROUP BY sido + ORDER BY count DESC + `, [today], (err, rows) => err ? reject(err) : resolve(rows)); + }); + + const total = stats.reduce((sum, row) => sum + row.count, 0); + + res.json({ stats, total }); + } catch (error) { + console.error('[Festival Stats] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 월별 축제 통계 + * GET /api/festivals/stats/by-month + */ +app.get('/api/festivals/stats/by-month', async (req, res) => { + try { + const today = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + + // 월별 축제 수 집계 + const monthlyStats = await new Promise((resolve, reject) => { + db.all(` + SELECT + substr(event_start_date, 1, 6) as year_month, + COUNT(*) as count + FROM festivals + WHERE is_active = 1 + AND event_end_date >= ? + GROUP BY substr(event_start_date, 1, 6) + ORDER BY year_month ASC + `, [today], (err, rows) => err ? reject(err) : resolve(rows)); + }); + + // 현재 월, 다음 월 계산 + const now = new Date(); + const currentYearMonth = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`; + const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const nextYearMonth = `${nextMonth.getFullYear()}${String(nextMonth.getMonth() + 1).padStart(2, '0')}`; + + // 현재 월 진행중 축제 (시작일이 이번달 이전이고 종료일이 오늘 이후) + const currentMonthFestivals = await new Promise((resolve, reject) => { + db.get(` + SELECT COUNT(*) as count + FROM festivals + WHERE is_active = 1 + AND event_start_date <= ? + AND event_end_date >= ? + `, [currentYearMonth + '31', today], (err, row) => err ? reject(err) : resolve(row)); + }); + + // 다음 월 축제 + const nextMonthFestivals = await new Promise((resolve, reject) => { + db.get(` + SELECT COUNT(*) as count + FROM festivals + WHERE is_active = 1 + AND substr(event_start_date, 1, 6) = ? + `, [nextYearMonth], (err, row) => err ? reject(err) : resolve(row)); + }); + + // 월 이름 포맷팅 + const formatMonth = (ym) => { + if (!ym || ym.length !== 6) return ym; + const year = ym.slice(0, 4); + const month = parseInt(ym.slice(4, 6)); + return `${year}.${month}월`; + }; + + const formattedStats = monthlyStats.map(item => ({ + yearMonth: item.year_month, + label: formatMonth(item.year_month), + count: item.count + })); + + const total = monthlyStats.reduce((sum, row) => sum + row.count, 0); + + res.json({ + stats: formattedStats, + total, + currentMonth: { + yearMonth: currentYearMonth, + label: formatMonth(currentYearMonth), + count: currentMonthFestivals?.count || 0 + }, + nextMonth: { + yearMonth: nextYearMonth, + label: formatMonth(nextYearMonth), + count: nextMonthFestivals?.count || 0 + } + }); + } catch (error) { + console.error('[Festival Monthly Stats] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 관리자: 축제 데이터 동기화 + * POST /api/admin/sync/festivals + * Body: { areaCode?: string } + */ +app.post('/api/admin/sync/festivals', authenticateToken, requireAdmin, async (req, res) => { + try { + const { areaCode } = req.body; + + // 동기화는 백그라운드에서 실행하고 즉시 응답 + res.json({ + message: '축제 동기화가 시작되었습니다.', + hint: 'node server/scripts/syncData.js festivals 명령어로 직접 실행도 가능합니다.' + }); + + // Note: 실제 동기화는 별도 프로세스에서 실행 권장 + // const { spawn } = require('child_process'); + // spawn('node', ['scripts/syncData.js', 'festivals'], { cwd: __dirname }); + } catch (error) { + console.error('[Admin Sync Festivals] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * 관리자: 펜션 데이터 동기화 + * POST /api/admin/sync/pensions + * Body: { areaCode?: string } + */ +app.post('/api/admin/sync/pensions', authenticateToken, requireAdmin, async (req, res) => { + try { + const { areaCode } = req.body; + + res.json({ + message: '펜션 동기화가 시작되었습니다.', + hint: 'node server/scripts/syncData.js pensions 명령어로 직접 실행도 가능합니다.' + }); + } catch (error) { + console.error('[Admin Sync Pensions] 오류:', error); + res.status(500).json({ error: error.message }); + } +}); + +// ==================== END FESTIVAL & PENSION API ROUTES ==================== + + // 모든 기타 요청은 React 앱으로 전달 (SPA 라우팅 지원) app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../dist/index.html')); @@ -5019,4 +7731,12 @@ app.get('*', (req, res) => { app.listen(PORT, () => { console.log(`서버가 http://localhost:${PORT} 에서 실행 중입니다.`); + + // 스케줄러 시작 (주간 자동 생성) + try { + const schedulerService = require('./services/schedulerService'); + schedulerService.startScheduler(); + } catch (error) { + console.error('스케줄러 시작 실패:', error.message); + } }); \ No newline at end of file diff --git a/server/instagram/instagram_service.py b/server/instagram/instagram_service.py index deee360..eb949f9 100644 --- a/server/instagram/instagram_service.py +++ b/server/instagram/instagram_service.py @@ -139,9 +139,16 @@ def connect_account(): 'error_code': 'NO_DATA' }), 400 - username = data.get('username', '').strip() - password = data.get('password', '').strip() - verification_code = data.get('verification_code', '').strip() + # None 값 안전하게 처리 + raw_username = data.get('username') + raw_password = data.get('password') + raw_verification = data.get('verification_code') + + logger.info(f"[DEBUG] raw values - username: {type(raw_username)}, password: {type(raw_password)}, verification: {type(raw_verification)}") + + username = str(raw_username).strip() if raw_username else '' + password = str(raw_password).strip() if raw_password else '' + verification_code = str(raw_verification).strip() if raw_verification else '' if not username or not password: return jsonify({ diff --git a/server/migrations/001_festival_pension_tables.sql b/server/migrations/001_festival_pension_tables.sql new file mode 100644 index 0000000..b053393 --- /dev/null +++ b/server/migrations/001_festival_pension_tables.sql @@ -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); diff --git a/server/package-lock.json b/server/package-lock.json index 80f2359..b81936b 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,6 +8,8 @@ "name": "bizvibe-render-server", "version": "0.5.0", "dependencies": { + "@google-cloud/bigquery": "^8.1.1", + "@google-cloud/billing": "^5.1.1", "axios": "^1.13.2", "bcrypt": "^6.0.0", "cors": "^2.8.5", @@ -16,8 +18,9 @@ "googleapis": "^166.0.0", "jsonwebtoken": "^9.0.2", "multer": "^2.0.2", + "node-cron": "^4.2.1", "open": "^11.0.0", - "puppeteer": "^22.0.0", + "puppeteer": "^19.0.0", "puppeteer-screen-recorder": "^3.0.0", "resend": "^6.5.2", "sqlite3": "^5.1.7" @@ -183,6 +186,147 @@ "license": "MIT", "optional": true }, + "node_modules/@google-cloud/bigquery": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@google-cloud/bigquery/-/bigquery-8.1.1.tgz", + "integrity": "sha512-2GHlohfA/VJffTvibMazMsZi6jPRx8MmaMberyDTL8rnhVs/frKSXVVRtLU83uSAy2j/5SD4mOs4jMQgJPON2g==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/common": "^6.0.0", + "@google-cloud/paginator": "^6.0.0", + "@google-cloud/precise-date": "^5.0.0", + "@google-cloud/promisify": "^5.0.0", + "arrify": "^3.0.0", + "big.js": "^6.2.2", + "duplexify": "^4.1.3", + "extend": "^3.0.2", + "stream-events": "^1.0.5", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/billing": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@google-cloud/billing/-/billing-5.1.1.tgz", + "integrity": "sha512-UDrWnXHk5rS/KHKhoi/IfJK2NwHQXha6VqUATEnyirwbJut5kdrxomJgF09ItHlyA7HqNHpmwoerUzQj8ZFFag==", + "license": "Apache-2.0", + "dependencies": { + "google-gax": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/common": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-6.0.0.tgz", + "integrity": "sha512-IXh04DlkLMxWgYLIUYuHHKXKOUwPDzDgke1ykkkJPe48cGIS9kkL2U/o0pm4ankHLlvzLF/ma1eO86n/bkumIA==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "^4.0.0", + "arrify": "^2.0.0", + "duplexify": "^4.1.3", + "extend": "^3.0.2", + "google-auth-library": "^10.0.0-rc.1", + "html-entities": "^2.5.2", + "retry-request": "^8.0.0", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/common/node_modules/@google-cloud/promisify": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.1.0.tgz", + "integrity": "sha512-G/FQx5cE/+DqBbOpA5jKsegGwdPniU6PuIEMt+qxWgFxvxuFOzVmp6zYchtYuwAWV5/8Dgs0yAmjvNZv3uXLQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/common/node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-6.0.0.tgz", + "integrity": "sha512-g5nmMnzC+94kBxOKkLGpK1ikvolTFCC3s2qtE4F+1EuArcJ7HHC23RDQVt3Ra3CqpUYZ+oXNKZ8n5Cn5yug8DA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/precise-date": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/precise-date/-/precise-date-5.0.0.tgz", + "integrity": "sha512-9h0Gvw92EvPdE8AK8AgZPbMnH5ftDyPtKm7/KUfcJVaPEPjwGDsJd1QV0H8esBDV4II41R/2lDWH1epBqIoKUw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-5.0.0.tgz", + "integrity": "sha512-N8qS6dlORGHwk7WjGXKOSsLjIjNINCPicsOX6gyyLiYk7mq3MtII96NZ9N2ahwA2vnkLmZODOIH9rlNniYWvCQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.2.tgz", + "integrity": "sha512-QzVUtEFyu05UNx2xr0fCQmStUO17uVQhGNowtxs00IgTZT6/W2PBLfUkj30s0FKJ29VtTa3ArVNIhNP6akQhqA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -279,6 +423,16 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -378,35 +532,119 @@ "node": ">=14" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@puppeteer/browsers": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", - "integrity": "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-0.5.0.tgz", + "integrity": "sha512-Uw6oB7VvmPRLE4iKsjuOh8zgDabhNX67dzo8U/BB0f9527qx+4eeUs+korU98OhG5C4ubg7ufBgVi63XYwS6TQ==", "license": "Apache-2.0", "dependencies": { - "debug": "^4.3.5", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.4.0", - "semver": "^7.6.3", - "tar-fs": "^3.0.6", - "unbzip2-stream": "^1.4.3", - "yargs": "^17.7.2" + "debug": "4.3.4", + "extract-zip": "2.0.1", + "https-proxy-agent": "5.0.1", + "progress": "2.0.3", + "proxy-from-env": "1.1.0", + "tar-fs": "2.1.1", + "unbzip2-stream": "1.4.3", + "yargs": "17.7.1" }, "bin": { "browsers": "lib/cjs/main-cli.js" }, "engines": { - "node": ">=18" + "node": ">=14.1.0" + }, + "peerDependencies": { + "typescript": ">= 4.7.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@puppeteer/browsers/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" } }, "node_modules/@puppeteer/browsers/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "ms": "2.1.2" }, "engines": { "node": ">=6.0" @@ -417,12 +655,43 @@ } } }, + "node_modules/@puppeteer/browsers/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@puppeteer/browsers/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "license": "MIT" }, + "node_modules/@puppeteer/browsers/node_modules/yargs": { + "version": "17.7.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.1.tgz", + "integrity": "sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@stablelib/base64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", @@ -439,18 +708,11 @@ "node": ">= 6" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", - "optional": true, "dependencies": { "undici-types": "~7.16.0" } @@ -585,16 +847,16 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "node_modules/arrify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-3.0.0.tgz", + "integrity": "sha512-tLkvA81vQG/XqE2mjDkGQHoOINtMHtysSnemrmoGe6PydDPMRbVugqyk4A6V/WDWEfm3l+0d8anA9r8cv/5Jaw==", "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, "engines": { - "node": ">=4" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/async": { @@ -619,117 +881,12 @@ "proxy-from-env": "^1.1.0" } }, - "node_modules/b4a": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", - "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.1.tgz", - "integrity": "sha512-zGUCsm3yv/ePt2PHNbVxjjn0nNB1MkIaR4wOCxJ2ig5pCf5cCVAYJXVhQg/3OhhJV6DB1ts7Hv0oUaElc2TPQg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", - "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -750,15 +907,6 @@ ], "license": "MIT" }, - "node_modules/basic-ftp": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", - "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -773,6 +921,19 @@ "node": ">= 18" } }, + "node_modules/big.js": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-6.2.2.tgz", + "integrity": "sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/bigjs" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -1112,14 +1273,12 @@ } }, "node_modules/chromium-bidi": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.3.tgz", - "integrity": "sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A==", + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.7.tgz", + "integrity": "sha512-6+mJuFXwTMU6I3vYLs6IL8A1DyQTPjCfIL971X0aMPVGRbGnNfl6i6Cl0NMbxi2bRYLGESt9T2ZIMRM5PAEcIQ==", "license": "Apache-2.0", "dependencies": { - "mitt": "3.0.1", - "urlpattern-polyfill": "10.0.0", - "zod": "3.23.8" + "mitt": "3.0.0" }, "peerDependencies": { "devtools-protocol": "*" @@ -1293,27 +1452,48 @@ } }, "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.1.3.tgz", + "integrity": "sha512-/UkO2JKI18b5jVMJUp0lvKFMpa/Gye+ZgZjKD+DGEN9y7NRcf/nK1A0sp67ONmKtnDCNMS44E6jrk0Yc3bDuUw==", "license": "MIT", "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", + "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" + "parse-json": "^5.0.0", + "path-type": "^4.0.0" }, "engines": { "node": ">=14" }, "funding": { "url": "https://github.com/sponsors/d-fischer" + } + }, + "node_modules/cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "license": "MIT", + "dependencies": { + "node-fetch": "2.6.7" + } + }, + "node_modules/cross-fetch/node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" }, "peerDependencies": { - "typescript": ">=4.9.5" + "encoding": "^0.1.0" }, "peerDependenciesMeta": { - "typescript": { + "encoding": { "optional": true } } @@ -1347,15 +1527,6 @@ "node": ">= 8" } }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1429,20 +1600,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1488,9 +1645,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1312386", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", - "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==", + "version": "0.0.1107588", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1107588.tgz", + "integrity": "sha512-yIR+pG9x65Xko7bErCUSQaDLrO/P1p3JUzEk7JCU4DowPcGHkTGUGQapcfcLc4qj0UaALwZ+cr0riFgiqpixcg==", "license": "BSD-3-Clause" }, "node_modules/dotenv": { @@ -1519,6 +1676,18 @@ "node": ">= 0.4" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1592,6 +1761,7 @@ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "license": "MIT", + "optional": true, "engines": { "node": ">=6" } @@ -1678,58 +1848,6 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1739,15 +1857,6 @@ "node": ">= 0.6" } }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1852,12 +1961,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, "node_modules/fast-sha256": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", @@ -2180,43 +2283,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/get-uri/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/get-uri/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -2261,6 +2327,28 @@ "node": ">=18" } }, + "node_modules/google-gax": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.6.tgz", + "integrity": "sha512-1kGbqVQBZPAAu4+/R1XxPQKP0ydbNYoLAr4l0ZO2bMV0kLyLW4I1gAk++qBLWt7DPORTzmWRMsCZe86gDjShJA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "duplexify": "^4.1.3", + "google-auth-library": "^10.1.0", + "google-logging-utils": "^1.1.1", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^3.0.0", + "protobufjs": "^7.5.3", + "retry-request": "^8.0.0", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/google-logging-utils": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", @@ -2387,6 +2475,22 @@ "node": ">= 0.4" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -2410,42 +2514,6 @@ "node": ">= 0.8" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/http-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/http-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -2596,6 +2664,7 @@ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", + "optional": true, "engines": { "node": ">= 12" } @@ -2821,6 +2890,12 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -2863,14 +2938,11 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, - "node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" - } + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" }, "node_modules/make-fetch-happen": { "version": "9.1.0", @@ -3283,9 +3355,9 @@ } }, "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.0.tgz", + "integrity": "sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ==", "license": "MIT" }, "node_modules/mkdirp": { @@ -3357,15 +3429,6 @@ "node": ">= 0.6" } }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/node-abi": { "version": "3.85.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", @@ -3387,6 +3450,15 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -3591,6 +3663,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -3660,61 +3741,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/pac-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -3807,6 +3833,15 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -3875,22 +3910,6 @@ "tar-stream": "^2.1.4" } }, - "node_modules/prebuild-install/node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -3921,6 +3940,42 @@ "node": ">=10" } }, + "node_modules/proto3-json-serializer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz", + "integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3934,48 +3989,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3993,48 +4006,70 @@ } }, "node_modules/puppeteer": { - "version": "22.15.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-22.15.0.tgz", - "integrity": "sha512-XjCY1SiSEi1T7iSYuxS82ft85kwDJUS7wj1Z0eGVXKdtr5g4xnVcbjwxhq5xBnpK/E7x1VZZoJDxpjAOasHT4Q==", + "version": "19.11.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-19.11.1.tgz", + "integrity": "sha512-39olGaX2djYUdhaQQHDZ0T0GwEp+5f9UB9HmEP0qHfdQHIq0xGQZuAZ5TLnJIc/88SrPLpEflPC+xUqOTv3c5g==", "deprecated": "< 24.15.0 is no longer supported", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.3.0", - "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1312386", - "puppeteer-core": "22.15.0" - }, - "bin": { - "puppeteer": "lib/esm/puppeteer/node/cli.js" - }, - "engines": { - "node": ">=18" + "@puppeteer/browsers": "0.5.0", + "cosmiconfig": "8.1.3", + "https-proxy-agent": "5.0.1", + "progress": "2.0.3", + "proxy-from-env": "1.1.0", + "puppeteer-core": "19.11.1" } }, "node_modules/puppeteer-core": { - "version": "22.15.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.15.0.tgz", - "integrity": "sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA==", + "version": "19.11.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-19.11.1.tgz", + "integrity": "sha512-qcuC2Uf0Fwdj9wNtaTZ2OvYRraXpAK+puwwVW8ofOhOgLPZyz1c68tsorfIZyCUOpyBisjr+xByu7BMbEYMepA==", "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.3.0", - "chromium-bidi": "0.6.3", - "debug": "^4.3.6", - "devtools-protocol": "0.0.1312386", - "ws": "^8.18.0" + "@puppeteer/browsers": "0.5.0", + "chromium-bidi": "0.4.7", + "cross-fetch": "3.1.5", + "debug": "4.3.4", + "devtools-protocol": "0.0.1107588", + "extract-zip": "2.0.1", + "https-proxy-agent": "5.0.1", + "proxy-from-env": "1.1.0", + "tar-fs": "2.1.1", + "unbzip2-stream": "1.4.3", + "ws": "8.13.0" }, "engines": { - "node": ">=18" + "node": ">=14.14.0" + }, + "peerDependencies": { + "typescript": ">= 4.7.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/puppeteer-core/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" } }, "node_modules/puppeteer-core/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "ms": "2.1.2" }, "engines": { "node": ">=6.0" @@ -4045,10 +4080,23 @@ } } }, + "node_modules/puppeteer-core/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/puppeteer-core/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "license": "MIT" }, "node_modules/puppeteer-screen-recorder": { @@ -4087,6 +4135,54 @@ "puppeteer": "19.0.0" } }, + "node_modules/puppeteer/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/puppeteer/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/puppeteer/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -4215,6 +4311,19 @@ "node": ">= 4" } }, + "node_modules/retry-request": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.2.tgz", + "integrity": "sha512-JzFPAfklk1kjR1w76f0QOIhoDkNkSqW8wYKT08n9yysTmZfB+RQ2QoXoTAeOi1HD9ZipTyTAZg3c4pM/jeqgSw==", + "license": "MIT", + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", @@ -4525,6 +4634,7 @@ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "license": "MIT", + "optional": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -4535,6 +4645,7 @@ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "license": "MIT", + "optional": true, "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" @@ -4544,53 +4655,6 @@ "npm": ">= 3.0.0" } }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/socks-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socks-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/sqlite3": { "version": "5.1.7", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", @@ -4656,6 +4720,21 @@ "node": ">= 0.8" } }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -4664,17 +4743,6 @@ "node": ">=10.0.0" } }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -4747,6 +4815,12 @@ "node": ">=0.10.0" } }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -4810,28 +4884,37 @@ } }, "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", "license": "MIT", "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" + "tar-stream": "^2.1.4" } }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "license": "MIT", "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" } }, "node_modules/tar/node_modules/minipass": { @@ -4843,15 +4926,92 @@ "node": ">=8" } }, - "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "node_modules/teeny-request": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.0.tgz", + "integrity": "sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==", "license": "Apache-2.0", "dependencies": { - "b4a": "^1.6.4" + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=18" } }, + "node_modules/teeny-request/node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -4867,6 +5027,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -4881,6 +5047,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, "node_modules/tunnel-agent": { @@ -4928,8 +5095,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/unique-filename": { "version": "1.1.1", @@ -4976,12 +5142,6 @@ "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", "license": "BSD" }, - "node_modules/urlpattern-polyfill": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", - "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==", - "license": "MIT" - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5028,6 +5188,22 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", @@ -5092,9 +5268,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -5188,15 +5364,6 @@ "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } - }, - "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/server/package.json b/server/package.json index 4b68a15..718a268 100644 --- a/server/package.json +++ b/server/package.json @@ -7,6 +7,8 @@ "start": "node index.js" }, "dependencies": { + "@google-cloud/bigquery": "^8.1.1", + "@google-cloud/billing": "^5.1.1", "axios": "^1.13.2", "bcrypt": "^6.0.0", "cors": "^2.8.5", @@ -15,8 +17,9 @@ "googleapis": "^166.0.0", "jsonwebtoken": "^9.0.2", "multer": "^2.0.2", + "node-cron": "^4.2.1", "open": "^11.0.0", - "puppeteer": "^22.0.0", + "puppeteer": "^19.0.0", "puppeteer-screen-recorder": "^3.0.0", "resend": "^6.5.2", "sqlite3": "^5.1.7" diff --git a/server/scripts/syncData.js b/server/scripts/syncData.js new file mode 100644 index 0000000..543ace9 --- /dev/null +++ b/server/scripts/syncData.js @@ -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(); diff --git a/server/scripts/weeklySync.sh b/server/scripts/weeklySync.sh new file mode 100755 index 0000000..e186a59 --- /dev/null +++ b/server/scripts/weeklySync.sh @@ -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" diff --git a/server/services/autoUploadService.js b/server/services/autoUploadService.js new file mode 100644 index 0000000..7a9a9cb --- /dev/null +++ b/server/services/autoUploadService.js @@ -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 +}; diff --git a/server/services/festivalService.js b/server/services/festivalService.js new file mode 100644 index 0000000..853a3b5 --- /dev/null +++ b/server/services/festivalService.js @@ -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; diff --git a/server/services/geocodingService.js b/server/services/geocodingService.js new file mode 100644 index 0000000..b4ae972 --- /dev/null +++ b/server/services/geocodingService.js @@ -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, +}; diff --git a/server/services/schedulerService.js b/server/services/schedulerService.js new file mode 100644 index 0000000..cc612e6 --- /dev/null +++ b/server/services/schedulerService.js @@ -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 +}; diff --git a/server/services/tourApiClient.js b/server/services/tourApiClient.js new file mode 100644 index 0000000..cfdc7f9 --- /dev/null +++ b/server/services/tourApiClient.js @@ -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, +}; diff --git a/services/geminiService.ts b/services/geminiService.ts index d2a4454..c779a3e 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -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'; const SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']; @@ -308,7 +308,7 @@ export const extractTextEffectFromImage = async ( const imageForBackend = await fileToBase64(imageFile); const response = await fetch('/api/gemini/text-effect', { method: 'POST', - headers: { + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')}` }, @@ -324,4 +324,57 @@ export const extractTextEffectFromImage = async ( console.error("Extract Text Effect (Frontend) Failed:", e); throw new Error(e.message || "텍스트 스타일 분석에 실패했습니다."); } +}; + +/** + * 비즈니스 DNA 분석 (Gemini 2.5 Flash + Google Search Grounding) - 백엔드 프록시 이용 + * 펜션/숙소의 브랜드 DNA를 분석하여 톤앤매너, 타겟 고객, 컬러, 키워드, 시각적 스타일을 추출 + * @param {string} nameOrUrl - 펜션 이름 또는 URL + * @param {File[]} images - 펜션 이미지들 (선택) + * @param {(progress: string) => void} onProgress - 진행 상황 콜백 + * @returns {Promise} - 분석된 DNA 데이터 + */ +export const analyzeBusinessDNA = async ( + nameOrUrl: string, + images?: File[], + onProgress?: (progress: string) => void +): Promise => { + 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 분석에 실패했습니다."); + } }; \ No newline at end of file diff --git a/services/googlePlacesService.ts b/services/googlePlacesService.ts index 9b5f984..93bbbda 100644 --- a/services/googlePlacesService.ts +++ b/services/googlePlacesService.ts @@ -58,7 +58,7 @@ export const crawlGooglePlace = async ( onProgress?: (msg: string) => void, options?: CrawlOptions ): Promise> => { - const maxImages = options?.maxImages ?? 15; + const maxImages = options?.maxImages ?? 100; const existingFingerprints = options?.existingFingerprints ?? new Set(); onProgress?.("Google 지도 정보 가져오는 중..."); diff --git a/services/instagramService.ts b/services/instagramService.ts new file mode 100644 index 0000000..e624082 --- /dev/null +++ b/services/instagramService.ts @@ -0,0 +1,160 @@ +import { BusinessInfo } from '../types'; + +/** + * 헬퍼 함수: URL에서 파일을 가져와 File 객체로 변환합니다. + * CORS 문제를 해결하기 위해 백엔드 프록시를 사용합니다. + */ +const urlToFile = async (url: string, filename: string): Promise => { + 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 => { + 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> => { + const fingerprints = new Set(); + 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; +} + +/** + * 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>} - 비즈니스 정보의 부분 객체 + */ +export const crawlInstagramProfile = async ( + url: string, + onProgress?: (msg: string) => void, + options?: CrawlOptions +): Promise> => { + const maxImages = options?.maxImages ?? 100; + const existingFingerprints = options?.existingFingerprints ?? new Set(); + + 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 || "인스타그램 프로필 정보를 가져오는데 실패했습니다."); + } +}; diff --git a/services/naverService.ts b/services/naverService.ts index d06b2d3..2e67952 100644 --- a/services/naverService.ts +++ b/services/naverService.ts @@ -58,7 +58,7 @@ export const getExistingFingerprints = async (existingImages: File[]): Promise; // 중복 검사용 기존 이미지 fingerprints } @@ -75,7 +75,7 @@ export const crawlNaverPlace = async ( onProgress?: (msg: string) => void, options?: CrawlOptions ): Promise> => { - const maxImages = options?.maxImages ?? 15; + const maxImages = options?.maxImages ?? 100; const existingFingerprints = options?.existingFingerprints ?? new Set(); onProgress?.("네이버 플레이스 정보 가져오는 중 (서버 요청)..."); diff --git a/setup-nginx-castad1.sh b/setup-nginx-castad1.sh new file mode 100755 index 0000000..549ea4c --- /dev/null +++ b/setup-nginx-castad1.sh @@ -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 "" diff --git a/src/components/DNACard.tsx b/src/components/DNACard.tsx new file mode 100644 index 0000000..0f03931 --- /dev/null +++ b/src/components/DNACard.tsx @@ -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 = ({ dna, className, compact = false }) => { + if (compact) { + return ( +
+
+
+ +
+
+

{dna.name}

+ {dna.tagline && ( +

{dna.tagline}

+ )} +
+
+ + {/* Color Palette (mini) */} +
+ {dna.brandColors.palette?.slice(0, 5).map((color, idx) => ( +
+ ))} +
+ + {/* Keywords (mini) */} +
+ {dna.keywords.primary.slice(0, 4).map((keyword, idx) => ( + + {keyword} + + ))} +
+
+ ); + } + + return ( +
+ {/* Header with gradient */} +
+
+
+ + Business DNA +
+ {dna.confidence && ( + + 신뢰도 {Math.round(dna.confidence * 100)}% + + )} +
+

{dna.name}

+ {dna.tagline && ( +

{dna.tagline}

+ )} +
+ +
+ {/* Tone & Manner */} +
+
+ +

톤 & 매너

+
+
+ {dna.toneAndManner.primary} + {dna.toneAndManner.secondary && ( + {dna.toneAndManner.secondary} + )} +
+

{dna.toneAndManner.description}

+
+ + {/* Target Customers */} +
+
+ +

타겟 고객

+
+
+ {dna.targetCustomers.primary} + {dna.targetCustomers.secondary?.map((target, idx) => ( + {target} + ))} +
+ {dna.targetCustomers.ageRange && ( +

+ 예상 연령대: {dna.targetCustomers.ageRange} +

+ )} + {dna.targetCustomers.characteristics && ( +
+ {dna.targetCustomers.characteristics.map((char, idx) => ( + + {char} + + ))} +
+ )} +
+ + {/* Brand Colors */} +
+
+ +

브랜드 컬러

+
+
+ {dna.brandColors.palette?.map((color, idx) => ( +
+
+ {color} +
+ ))} +
+

+ {dna.brandColors.mood} +

+
+ + {/* Visual Style */} +
+
+ +

시각적 스타일

+
+
+
+
+ + 인테리어 +
+

{dna.visualStyle.interior}

+
+
+
+ + 외관 +
+

{dna.visualStyle.exterior}

+
+
+
+

분위기

+

{dna.visualStyle.atmosphere}

+

추천 사진 스타일: {dna.visualStyle.photoStyle}

+ {dna.visualStyle.suggestedFilters && ( +
+ {dna.visualStyle.suggestedFilters.map((filter, idx) => ( + + {filter} + + ))} +
+ )} +
+
+ + {/* Keywords & Hashtags */} +
+
+ +

키워드 & 해시태그

+
+
+ {dna.keywords.primary.map((keyword, idx) => ( + + {keyword} + + ))} +
+ {dna.keywords.secondary && ( +
+ {dna.keywords.secondary.map((keyword, idx) => ( + + {keyword} + + ))} +
+ )} + {dna.keywords.hashtags && ( +
+ {dna.keywords.hashtags.map((tag, idx) => ( + + {tag} + + ))} +
+ )} +
+ + {/* Unique Selling Points */} +
+
+ +

차별화 포인트

+
+
    + {dna.uniqueSellingPoints.map((usp, idx) => ( +
  • + + {usp} +
  • + ))} +
+
+ + {/* Mood */} +
+
+ +

분위기 & 감정

+
+ {dna.mood.primary} +
+ {dna.mood.emotions.map((emotion, idx) => ( + + {emotion} + + ))} +
+
+ + {/* Sources */} + {dna.sources && dna.sources.length > 0 && ( +
+

분석 소스

+
+ {dna.sources.map((source, idx) => ( + + + {new URL(source).hostname} + + ))} +
+
+ )} + + {/* Analyzed At */} + {dna.analyzedAt && ( +

+ 분석 시간: {new Date(dna.analyzedAt).toLocaleString('ko-KR')} +

+ )} +
+
+ ); +}; + +export default DNACard; diff --git a/src/components/KoreaMap.tsx b/src/components/KoreaMap.tsx new file mode 100644 index 0000000..f3e132b --- /dev/null +++ b/src/components/KoreaMap.tsx @@ -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 = { + '서울': { + 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 = ({ data, onRegionClick, className }) => { + const [hoveredRegion, setHoveredRegion] = useState(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 ( +
+ + {/* 배경 */} + + + {/* 지역별 경로 */} + {Object.entries(REGION_PATHS).map(([sido, { path, labelX, labelY }]) => { + const count = getCount(sido); + const isHovered = hoveredRegion === sido; + + return ( + + {/* 지역 영역 */} + setHoveredRegion(sido)} + onMouseLeave={() => setHoveredRegion(null)} + onClick={() => onRegionClick?.(sido)} + /> + + {/* 지역명 & 축제 수 */} + + {sido} + + {count > 0 && ( + + {count}개 + + )} + + ); + })} + + + {/* 호버 툴팁 */} + {hoveredRegion && ( +
+

{hoveredRegion}

+

+ {getCount(hoveredRegion)}개 축제 +

+
+ )} + + {/* 범례 */} +
+ 적음 +
+ {['#e5e7eb', '#fed7aa', '#fdba74', '#fb923c', '#f97316', '#ea580c'].map((color, i) => ( +
+ ))} +
+ 많음 +
+
+ ); +}; + +export default KoreaMap; diff --git a/src/components/PensionOnboardingDialog.tsx b/src/components/PensionOnboardingDialog.tsx new file mode 100644 index 0000000..63a8516 --- /dev/null +++ b/src/components/PensionOnboardingDialog.tsx @@ -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 = ({ 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([]); + + // 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 = { + 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 ( + { + // Prevent closing by clicking outside + if (!isOpen && !submitting) { + // Allow closing only via skip button + } + }}> + e.preventDefault()}> + + {/* Icon */} +
+ +
+ + + 펜션 정보를 등록해주세요 + + + 영상을 만들기 위해 펜션 기본 정보가 필요합니다. +
+ + 나중에 설정에서 수정할 수 있습니다. + +
+
+ +
+ {error && ( +
+ + {error} +
+ )} + + {/* URL Input - Optional */} +
+ +

+ 네이버 플레이스, 구글 지도 또는 인스타그램 URL을 입력하면 자동으로 정보와 사진을 가져옵니다. +

+
+ setFormData({ ...formData, sourceUrl: e.target.value })} + placeholder="https://naver.me/... 또는 https://instagram.com/username" + disabled={submitting || isFetching} + className="flex-1" + /> + +
+ {fetchProgress && ( +
+ {isFetching && } + {fetchProgress} +
+ )} + {crawledImages.length > 0 && ( +
+ + {crawledImages.length}장의 사진이 준비되었습니다 +
+ )} +
+ + {/* Pension Name */} +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="예: 행복한 펜션" + disabled={submitting || isFetching} + autoFocus + /> +
+ + {/* Address */} +
+ + setFormData({ ...formData, address: e.target.value })} + placeholder="예: 강원도 강릉시 사천면" + disabled={submitting || isFetching} + /> +
+ + {/* Description */} +
+ +