diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md index c416b12..f3f2c51 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/DEPLOYMENT_GUIDE.md @@ -1,15 +1,326 @@ # AutonetSellCar.com 배포 가이드 -이 문서는 코드 수정부터 운영 배포까지의 전체 과정을 설명합니다. +이 문서는 시스템 아키텍처와 코드 수정부터 운영 배포까지의 전체 과정을 설명합니다. --- -## 1. 전체 배포 흐름 개요 +## 1. 시스템 아키텍처 개요 ``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ 배포 파이프라인 전체 흐름 │ -└─────────────────────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ AutonetSellCar.com 시스템 아키텍처 │ +└─────────────────────────────────────────────────────────────────────────────────┘ + + ┌─────────────────┐ + │ 인터넷 │ + │ 59.14.158.123 │ + └────────┬────────┘ + │ + ┌──────────────────┼──────────────────┐ + │ │ │ + ▼ ▼ ▼ + autonetsellcar.com autonetsellcar.com autonetsellcar.com + / /api /uploads + │ │ │ + └──────────────────┼──────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 서버1 (192.168.0.201) │ +│ 인프라 서버 │ +│ ┌─────────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ Nginx Proxy │ │ PostgreSQL │ │ Redis │ │ Portainer/Grafana │ │ +│ │ Manager │ │ :5432 │ │ :6379 │ │ Prometheus │ │ +│ │ :80/:443 │ │ │ │ │ │ │ │ +│ │ │ │ DB: autonet │ │ │ │ │ │ +│ └────────┬────────┘ └──────┬──────┘ └──────┬──────┘ └─────────────────────┘ │ +│ │ │ │ │ +└───────────┼──────────────────┼────────────────┼──────────────────────────────────┘ + │ │ │ + │ └────────┬───────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 서버2 (192.168.0.202) │ +│ 애플리케이션 서버 │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐ │ +│ │ Production Environment │ │ +│ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │ +│ │ │ autonet-frontend │ │ autonet-backend │ │ carmodoo-agent │ │ │ +│ │ │ Next.js 14 │───▶│ FastAPI │───▶│ Playwright │ │ │ +│ │ │ :3000 │ │ :8000 │ │ PDF/Spec 조회 │ │ │ +│ │ └──────────────────┘ └────────┬─────────┘ └──────────────────┘ │ │ +│ │ │ │ │ +│ │ │ PostgreSQL/Redis │ │ +│ │ └──────────────────────────────────────┼───┼──▶ 서버1 +│ └─────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐ │ +│ │ Staging Environment │ │ +│ │ ┌──────────────────┐ ┌──────────────────┐ │ │ +│ │ │ frontend-staging │ │ backend-staging │ │ │ +│ │ │ :3001 │ │ :8001 │ │ │ +│ │ └──────────────────┘ └──────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 서버4 (개발 PC) │ +│ │ +│ D:\Workspace\claudeCode\AutonetSellCar.com\ │ +│ ├── frontend/ (Next.js) │ +│ ├── backend/ (FastAPI) │ +│ └── carmodoo-agent/ │ +│ │ +│ 로컬 테스트: localhost:3000 / localhost:8000 │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 서버별 역할 및 구성 + +### 서버1 (192.168.0.201) - 인프라 서버 + +| 서비스 | 포트 | 역할 | +|--------|------|------| +| Nginx Proxy Manager | 80, 443, 81 | 리버스 프록시, SSL 인증서 관리 | +| PostgreSQL | 5432 | 메인 데이터베이스 (autonet) | +| Redis | 6379 | 캐시, 세션 관리 | +| Portainer | 9000 | Docker 관리 UI | +| Prometheus | 9090 | 메트릭 수집 | +| Grafana | 3000 | 모니터링 대시보드 | + +### 서버2 (192.168.0.202) - 애플리케이션 서버 + +| 컨테이너 | 포트 | 역할 | +|----------|------|------| +| autonet-frontend | 3000 | Next.js 프론트엔드 (운영) | +| autonet-backend | 8000 | FastAPI 백엔드 (운영) | +| carmodoo-agent | - | Playwright 기반 PDF/스펙 조회 | +| autonet-frontend-staging | 3001 | 스테이징 프론트엔드 | +| autonet-backend-staging | 8001 | 스테이징 백엔드 | + +--- + +## 3. 도메인 및 라우팅 설정 + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ Nginx Proxy Manager 설정 │ +└─────────────────────────────────────────────────────────────────────────────────┘ + + autonetsellcar.com (SSL: Let's Encrypt) + │ + ├── / ──▶ 192.168.0.202:3000 (Frontend) + │ + ├── /api/* ──▶ 192.168.0.202:8000 (Backend) + │ └── Custom Location: /api + │ Forward: http://192.168.0.202:8000 + │ + └── /uploads/* ──▶ 192.168.0.202:8000 (Backend Static) + └── Custom Location: /uploads + Forward: http://192.168.0.202:8000 + + 설정 방법: + ┌─────────────────────────────────────────────────────────────────────────┐ + │ 1. Nginx Proxy Manager (http://192.168.0.201:81) 접속 │ + │ 2. Proxy Hosts > autonetsellcar.com 선택 │ + │ 3. Custom Locations 탭에서 /api, /uploads 추가 │ + │ - Location: /api │ + │ - Scheme: http │ + │ - Forward Hostname: 192.168.0.202 │ + │ - Forward Port: 8000 │ + └─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. PostgreSQL 설정 + +### 4.1 서버1 PostgreSQL Docker 컨테이너 + +```bash +# PostgreSQL 컨테이너 정보 확인 +docker exec postgres-primary env | grep POSTGRES + +# 출력: +# POSTGRES_USER=admin +# POSTGRES_PASSWORD=roskfl@1122 +# POSTGRES_DB=mongolcar +``` + +### 4.2 데이터베이스 생성 + +```bash +# autonet 데이터베이스 생성 +docker exec -it postgres-primary psql -U admin -d postgres -c "CREATE DATABASE autonet;" + +# 비밀번호 재설정 (필요시) +docker exec -it postgres-primary psql -U admin -d postgres -c "ALTER USER admin PASSWORD 'roskfl@1122';" +``` + +### 4.3 외부 접속 설정 확인 + +```bash +# pg_hba.conf 확인 (외부 접속 허용) +docker exec postgres-primary cat /var/lib/postgresql/data/pg_hba.conf | grep -v "^#" | grep -v "^$" + +# 필수 설정: +# host all all all scram-sha-256 +``` + +### 4.4 백엔드 PostgreSQL 연결 설정 + +**backend/.env:** +```env +# Database +USE_SQLITE=False +DB_HOST=192.168.0.201 +DB_PORT=5432 +DB_NAME=autonet +DB_USER=admin +DB_PASSWORD=roskfl@1122 +``` + +**backend/app/config.py:** +```python +@property +def DATABASE_URL(self) -> str: + if self.USE_SQLITE: + base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + db_path = os.path.join(base_dir, "autonet.db") + return f"sqlite:///{db_path}" + # URL-encode password for special characters like @ # etc + from urllib.parse import quote_plus + encoded_password = quote_plus(self.DB_PASSWORD) + return f"postgresql://{self.DB_USER}:{encoded_password}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" +``` + +### 4.5 SQLite → PostgreSQL 마이그레이션 + +```bash +# 개발 서버(서버4)에서 실행 +cd D:\Workspace\claudeCode\AutonetSellCar.com\backend + +# psycopg2 설치 +venv\Scripts\pip.exe install psycopg2-binary + +# 마이그레이션 스크립트 실행 +venv\Scripts\python.exe migrate_to_postgres.py +``` + +**마이그레이션 스크립트 주요 기능:** +- PostgreSQL에 테이블 자동 생성 (SQLAlchemy 모델 기반) +- SQLite boolean (0/1) → PostgreSQL boolean (true/false) 변환 +- FK 제약조건 임시 비활성화 후 데이터 이전 +- Sequence 자동 리셋 + +--- + +## 5. Docker Compose 설정 + +### 5.1 Docker Compose v2 설치 (서버2) + +```bash +# Docker 공식 저장소 추가 +sudo apt-get update +sudo apt-get install ca-certificates curl gnupg -y +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg + +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Docker Compose 플러그인 설치 +sudo apt-get update +sudo apt-get install docker-compose-plugin -y + +# 버전 확인 +docker compose version +``` + +### 5.2 docker-compose.production.yml + +```yaml +services: + frontend: + build: ./frontend + container_name: autonet-frontend + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - NEXT_PUBLIC_API_URL=https://autonetsellcar.com + depends_on: + - backend + networks: + - autonet-production-network + + backend: + build: ./backend + container_name: autonet-backend + ports: + - "8000:8000" + environment: + - ENV=production + env_file: + - ./backend/.env + volumes: + - ./backend/uploads:/app/uploads + networks: + - autonet-production-network + + carmodoo-agent: + build: ./carmodoo-agent + container_name: carmodoo-agent + networks: + - autonet-production-network + +networks: + autonet-production-network: + driver: bridge +``` + +### 5.3 주요 Docker 명령어 + +```bash +# 전체 시작 +docker compose -f docker-compose.production.yml up -d + +# 특정 서비스 재빌드 (캐시 없이) +docker compose -f docker-compose.production.yml build backend --no-cache +docker compose -f docker-compose.production.yml up -d backend + +# 로그 확인 +docker logs autonet-backend --tail 50 -f + +# 컨테이너 상태 +docker ps + +# 전체 중지 및 제거 +docker compose -f docker-compose.production.yml down + +# 네트워크/볼륨 문제 시 +docker rm -f autonet-backend autonet-frontend carmodoo-agent +docker network rm autonet-production-network +docker compose -f docker-compose.production.yml up -d +``` + +--- + +## 6. 배포 흐름 + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 배포 파이프라인 │ +└─────────────────────────────────────────────────────────────────────────────────┘ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ 1. 개발 │ ───▶ │ 2. 커밋 │ ───▶ │ 3. 스테이징 │ ───▶ │ 4. 운영 │ @@ -18,58 +329,89 @@ │ │ │ │ ▼ ▼ ▼ ▼ 코드 수정 git commit Docker 빌드 promote - 로컬 테스트 git push staging 포트 3001/8001 포트 3000/8000 + 로컬 테스트 git push staging :3001/:8001 :3000/:8000 +``` + +### Step 1: 개발 (서버4) + +```bash +# 로컬 개발 서버 실행 +cd backend && venv\Scripts\activate +uvicorn app.main:app --reload --port 8000 + +cd frontend +npm run dev + +# 테스트: http://localhost:3000 +``` + +### Step 2: Git Commit & Push + +```bash +git status +git add . +git commit -m "feat: 기능 설명" +git push staging main +``` + +### Step 3: 스테이징 테스트 + +```bash +# 서버2 SSH 접속 후 +cd /opt/autonet/staging +docker compose -f docker-compose.staging.yml build --no-cache +docker compose -f docker-compose.staging.yml up -d + +# 테스트 URL +# Frontend: http://192.168.0.202:3001 +# Backend: http://192.168.0.202:8001/docs +``` + +### Step 4: 운영 배포 + +```bash +# 서버2에서 +cd /opt/autonet/production +docker compose -f docker-compose.production.yml build --no-cache +docker compose -f docker-compose.production.yml up -d + +# 또는 스크립트 사용 +/opt/autonet/scripts/deploy.sh promote + +# 운영 URL +# https://autonetsellcar.com +# https://autonetsellcar.com/api/docs ``` --- -## 2. 서버 환경 구성 - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ 네트워크 구성도 │ -└─────────────────────────────────────────────────────────────────────────────┘ - - ┌────────────────────┐ ┌────────────────────────────────┐ - │ 서버4 │ │ 서버2 │ - │ (개발 서버) │ │ (운영 서버) │ - │ │ SSH/SCP │ 192.168.0.202 │ - │ ┌──────────────┐ │ ─────────────────▶ │ │ - │ │ 소스 코드 │ │ │ ┌────────────┐ ┌────────────┐ │ - │ │ (로컬 개발) │ │ │ │ Staging │ │ Production │ │ - │ └──────────────┘ │ │ │ :3001/:8001│ │ :3000/:8000│ │ - │ │ │ └────────────┘ └────────────┘ │ - │ D:\Workspace\ │ │ │ - │ claudeCode\ │ │ /opt/autonet/ │ - │ AutonetSellCar.com│ │ ├── staging/ │ - │ │ │ ├── production/ │ - │ │ │ └── git/autonet.git │ - └────────────────────┘ └────────────────────────────────┘ -``` - ---- - -## 3. 디렉토리 구조 +## 7. 디렉토리 구조 ``` 서버2 (/opt/autonet/) │ ├── git/ -│ └── autonet.git/ # Bare Git Repository +│ └── autonet.git/ # Bare Git Repository │ └── hooks/ -│ └── post-receive # 자동 배포 훅 +│ └── post-receive # 자동 배포 훅 │ -├── staging/ # 스테이징 환경 +├── staging/ # 스테이징 환경 │ ├── frontend/ │ ├── backend/ +│ │ └── .env # 스테이징 환경변수 │ └── docker-compose.staging.yml │ -├── production/ # 운영 환경 +├── production/ # 운영 환경 │ ├── frontend/ │ ├── backend/ +│ │ ├── .env # 운영 환경변수 (PostgreSQL) +│ │ ├── requirements.txt # psycopg2-binary 포함 +│ │ └── app/ +│ │ └── config.py # URL 인코딩 적용 +│ ├── carmodoo-agent/ │ └── docker-compose.production.yml │ -├── releases/ # 롤백용 백업 +├── releases/ # 롤백용 백업 │ ├── 20241230_140000/ │ └── 20241230_150000/ │ @@ -80,445 +422,159 @@ --- -## 4. 단계별 상세 설명 +## 8. 환경변수 설정 -### Step 1: 코드 수정 (서버4) +### 운영 서버 backend/.env 전체 -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Step 1: 코드 수정 │ -└─────────────────────────────────────────────────────────────────────────────┘ +```env +# Database (PostgreSQL) +USE_SQLITE=False +DB_HOST=192.168.0.201 +DB_PORT=5432 +DB_NAME=autonet +DB_USER=admin +DB_PASSWORD=roskfl@1122 - 개발자 PC (서버4) - ┌─────────────────────────────────────────┐ - │ │ - │ D:\Workspace\claudeCode\ │ - │ └── AutonetSellCar.com\ │ - │ ├── backend\ │ - │ │ └── app\ │ - │ │ ├── api\ ◀── 수정 │ - │ │ ├── models\ │ - │ │ └── schemas\ ◀── 수정 │ - │ │ │ - │ └── frontend\ │ - │ └── src\ │ - │ ├── app\ ◀── 수정 │ - │ ├── components\ ◀── 수정 │ - │ └── lib\ ◀── 수정 │ - │ │ - │ ┌─────────────────────────────────┐ │ - │ │ 로컬 테스트 서버 │ │ - │ │ Frontend: http://localhost:3000│ │ - │ │ Backend: http://localhost:8000│ │ - │ └─────────────────────────────────┘ │ - │ │ - └─────────────────────────────────────────┘ +# Redis +REDIS_HOST=192.168.0.201 +REDIS_PORT=6379 +REDIS_PASSWORD= - 명령어: - ┌─────────────────────────────────────────┐ - │ # Backend 실행 │ - │ cd backend │ - │ venv\Scripts\activate │ - │ uvicorn app.main:app --reload --port 8000│ - │ │ - │ # Frontend 실행 │ - │ cd frontend │ - │ npm run dev │ - └─────────────────────────────────────────┘ -``` +# JWT +SECRET_KEY=your-secret-key-change-in-production -### Step 2: Git Commit & Push +# App +DEBUG=False -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Step 2: Git Commit & Push │ -└─────────────────────────────────────────────────────────────────────────────┘ +# Azure Translator +AZURE_TRANSLATOR_KEY=your-azure-key +AZURE_TRANSLATOR_REGION=southeastasia - ┌───────────────┐ ┌───────────────┐ - │ 서버4 │ │ 서버2 │ - │ (개발) │ │ (운영) │ - └───────┬───────┘ └───────┬───────┘ - │ │ - │ 1. git add . │ - │ 2. git commit -m "message" │ - │ │ - ▼ │ - ┌───────────────┐ │ - │ Local Repo │ │ - │ (main) │ │ - └───────┬───────┘ │ - │ │ - │ 3. git push staging main │ - │ │ - │ SSH (포트 22) │ - │ ════════════════════════════════════▶ │ - │ ▼ - │ ┌───────────────┐ - │ │ Bare Repo │ - │ │ autonet.git │ - │ └───────┬───────┘ - │ │ - │ │ post-receive 훅 실행 - │ ▼ - │ ┌───────────────┐ - │ │ 스테이징 │ - │ │ 자동 배포 │ - │ └───────────────┘ +# Email (SMTP) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=autonetsellcar@gmail.com +SMTP_PASSWORD=your-app-password +SMTP_FROM_EMAIL=autonetsellcar@gmail.com +SMTP_FROM_NAME=AutonetSellCar - 명령어: - ┌─────────────────────────────────────────┐ - │ git status │ - │ git add . │ - │ git commit -m "feat: 기능 설명" │ - │ git push staging main │ - └─────────────────────────────────────────┘ -``` - -### Step 3: 스테이징 테스트 - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Step 3: 스테이징 테스트 │ -└─────────────────────────────────────────────────────────────────────────────┘ - - 서버2 스테이징 환경 - ┌─────────────────────────────────────────────────────────────────────┐ - │ │ - │ /opt/autonet/staging/ │ - │ │ - │ ┌─────────────────────┐ ┌─────────────────────┐ │ - │ │ Frontend Container │ │ Backend Container │ │ - │ │ │ │ │ │ - │ │ autonet-frontend │ │ autonet-backend │ │ - │ │ -staging │ │ -staging │ │ - │ │ │ │ │ │ - │ │ Port: 3001 ─────┼─────▶ Port: 8001 │ │ - │ │ │ API │ │ │ - │ └─────────────────────┘ └──────────┬──────────┘ │ - │ │ │ - │ ▼ │ - │ ┌─────────────────────┐ │ - │ │ staging-db │ │ - │ │ (SQLite/Volume) │ │ - │ └─────────────────────┘ │ - │ │ - └─────────────────────────────────────────────────────────────────────┘ - - 테스트 URL: - ┌─────────────────────────────────────────┐ - │ Frontend: http://192.168.0.202:3001 │ - │ Backend: http://192.168.0.202:8001 │ - │ API Docs: http://192.168.0.202:8001/docs│ - └─────────────────────────────────────────┘ - - 수동 빌드 명령어 (SSH 접속 후): - ┌─────────────────────────────────────────┐ - │ cd /opt/autonet/staging │ - │ docker compose -f docker-compose.staging.yml down│ - │ docker compose -f docker-compose.staging.yml build --no-cache│ - │ docker compose -f docker-compose.staging.yml up -d│ - └─────────────────────────────────────────┘ -``` - -### Step 4: 운영 배포 (Promote) - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Step 4: 운영 배포 (Promote) │ -└─────────────────────────────────────────────────────────────────────────────┘ - - ┌─────────────────────┐ ┌─────────────────────┐ - │ 스테이징 │ promote │ 운영 │ - │ (테스트 완료) │ ══════════▶ │ (서비스 중) │ - │ :3001 / :8001 │ │ :3000 / :8000 │ - └─────────────────────┘ └─────────────────────┘ - │ │ - │ ▼ - │ ┌─────────────────────┐ - │ │ 백업 생성 │ - │ │ /releases/ │ - │ │ 20241230_160000/ │ - │ └─────────────────────┘ - - 배포 흐름: - ┌─────────────────────────────────────────────────────────────────────┐ - │ │ - │ 1. 현재 운영 백업 │ - │ └── /opt/autonet/releases/20241230_160000/ │ - │ │ - │ 2. 운영 컨테이너 중지 │ - │ └── docker compose down │ - │ │ - │ 3. 스테이징 → 운영 복사 (DB/uploads 제외) │ - │ └── rsync -av --exclude='*.db' staging/ production/ │ - │ │ - │ 4. 운영 컨테이너 빌드 & 시작 │ - │ └── docker compose up -d --build │ - │ │ - │ 5. 헬스체크 │ - │ └── curl http://localhost:3000 && curl http://localhost:8000 │ - │ │ - │ 6. 실패 시 자동 롤백 │ - │ └── deploy.sh rollback │ - │ │ - └─────────────────────────────────────────────────────────────────────┘ - - 명령어: - ┌─────────────────────────────────────────┐ - │ # 스테이징 → 운영 승격 │ - │ /opt/autonet/scripts/deploy.sh promote │ - │ │ - │ # 롤백 (문제 발생 시) │ - │ /opt/autonet/scripts/deploy.sh rollback │ - │ │ - │ # 상태 확인 │ - │ /opt/autonet/scripts/deploy.sh status │ - └─────────────────────────────────────────┘ +# Verification +VERIFICATION_CODE_EXPIRE_MINUTES=10 +EMAIL_VERIFICATION_REQUIRED=True ``` --- -## 5. 수동 배포 방법 (SCP 직접 전송) +## 9. 문제 해결 -긴급 배포 또는 post-receive 훅 미설정 시 사용합니다. +### PostgreSQL 연결 오류 + +```bash +# 에러: could not translate host name "1122@192.168.0.201" +# 원인: 비밀번호에 @ 문자가 URL 구분자로 인식됨 +# 해결: config.py에서 URL 인코딩 적용 + +from urllib.parse import quote_plus +encoded_password = quote_plus(self.DB_PASSWORD) +``` + +### Docker 네트워크 충돌 + +```bash +# 에러: network was found but has incorrect label +docker compose -f docker-compose.production.yml down +docker network rm autonet-production-network +docker compose -f docker-compose.production.yml up -d +``` + +### 컨테이너 이름 충돌 + +```bash +# 에러: container name is already in use +docker rm -f autonet-backend autonet-frontend carmodoo-agent +docker compose -f docker-compose.production.yml up -d +``` + +### psycopg2 모듈 없음 + +```bash +# requirements.txt에 추가 +psycopg2-binary # PostgreSQL production + +# 또는 직접 추가 +echo "psycopg2-binary" >> /opt/autonet/production/backend/requirements.txt +docker compose -f docker-compose.production.yml build backend --no-cache +``` + +### Mixed Content 에러 ``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ 수동 배포 (SCP 방식) │ -└─────────────────────────────────────────────────────────────────────────────┘ - - 서버4 (개발) 서버2 (운영) - ┌───────────────┐ ┌───────────────┐ - │ │ │ │ - │ 수정된 파일 │ SCP 전송 │ production/ │ - │ frontend/ │ ═══════════════════════▶│ frontend/ │ - │ backend/ │ │ backend/ │ - │ │ │ │ - └───────────────┘ └───────┬───────┘ - │ - ▼ - ┌───────────────┐ - │ Docker 재빌드 │ - │ │ - │ docker build │ - │ docker run │ - └───────────────┘ - - Step 1: 파일 전송 (PowerShell에서) - ┌─────────────────────────────────────────────────────────────────────┐ - │ # Frontend 파일 전송 │ - │ scp -r frontend/src/app/admin/*.tsx \ │ - │ damon@192.168.0.202:/opt/autonet/production/frontend/src/app/admin/│ - │ │ - │ # Backend 파일 전송 │ - │ scp -r backend/app/* \ │ - │ damon@192.168.0.202:/opt/autonet/production/backend/app/ │ - └─────────────────────────────────────────────────────────────────────┘ - - Step 2: Docker 재빌드 (서버2 SSH 접속 후) - ┌─────────────────────────────────────────────────────────────────────┐ - │ cd /opt/autonet/production │ - │ │ - │ # Frontend 재빌드 │ - │ docker stop autonet-frontend │ - │ docker rm autonet-frontend │ - │ docker build -t autonet-frontend-prod \ │ - │ --build-arg NEXT_PUBLIC_API_URL=http://192.168.0.202:8000 \ │ - │ ./frontend │ - │ docker run -d --name autonet-frontend \ │ - │ -p 3000:3000 \ │ - │ -e NEXT_PUBLIC_API_URL=http://192.168.0.202:8000 \ │ - │ autonet-frontend-prod │ - │ │ - │ # Backend 재빌드 │ - │ docker stop autonet-backend │ - │ docker rm autonet-backend │ - │ docker build -t autonet-backend-prod ./backend │ - │ docker run -d --name autonet-backend \ │ - │ -p 8000:8000 \ │ - │ -v $(pwd)/backend/uploads:/app/uploads \ │ - │ -v $(pwd)/backend/autonet.db:/app/autonet.db \ │ - │ autonet-backend-prod │ - └─────────────────────────────────────────────────────────────────────┘ +# 에러: Mixed Content - HTTPS 페이지에서 HTTP API 호출 +# 해결: Frontend 빌드 시 HTTPS API URL 사용 +NEXT_PUBLIC_API_URL=https://autonetsellcar.com ``` --- -## 6. Docker 컨테이너 구성 +## 10. 빠른 참조 명령어 ``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Docker 컨테이너 구성도 │ -└─────────────────────────────────────────────────────────────────────────────┘ +╔═══════════════════════════════════════════════════════════════════════════════╗ +║ 개발 (서버4) ║ +╠═══════════════════════════════════════════════════════════════════════════════╣ +║ git status # 변경 파일 확인 ║ +║ git add . # 스테이징 ║ +║ git commit -m "message" # 커밋 ║ +║ git push staging main # 서버2로 푸시 ║ +╚═══════════════════════════════════════════════════════════════════════════════╝ - 운영 환경 (Production) - ┌─────────────────────────────────────────────────────────────────────┐ - │ │ - │ ┌─────────────────────────────────────────────────────────────┐ │ - │ │ Docker Network │ │ - │ │ │ │ - │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ - │ │ │ │ │ │ │ │ - │ │ │ autonet-frontend│ API │ autonet-backend │ │ │ - │ │ │ │───────▶│ │ │ │ - │ │ │ Next.js 14 │ │ FastAPI │ │ │ - │ │ │ Port: 3000 │ │ Port: 8000 │ │ │ - │ │ │ │ │ │ │ │ - │ │ └──────────────────┘ └────────┬─────────┘ │ │ - │ │ │ │ │ - │ │ ┌────────▼─────────┐ │ │ - │ │ │ │ │ │ - │ │ │ Volumes │ │ │ - │ │ │ - autonet.db │ │ │ - │ │ │ - uploads/ │ │ │ - │ │ │ │ │ │ - │ │ └──────────────────┘ │ │ - │ │ │ │ - │ └──────────────────────────────────────────────────────────────┘ │ - │ │ - │ ┌──────────────────┐ │ - │ │ carmodoo-agent │ (Playwright 브라우저 자동화) │ - │ │ - PDF 생성 │ │ - │ │ - 차량 스펙 조회 │ │ - │ └──────────────────┘ │ - │ │ - └─────────────────────────────────────────────────────────────────────┘ +╔═══════════════════════════════════════════════════════════════════════════════╗ +║ 운영 (서버2) ║ +╠═══════════════════════════════════════════════════════════════════════════════╣ +║ docker ps # 실행 중인 컨테이너 ║ +║ docker logs autonet-backend --tail 50 # 백엔드 로그 ║ +║ docker compose -f docker-compose.production.yml up -d # 시작 ║ +║ docker compose -f docker-compose.production.yml down # 중지 ║ +║ docker compose -f docker-compose.production.yml build --no-cache # 재빌드 ║ +╚═══════════════════════════════════════════════════════════════════════════════╝ - 포트 매핑: - ┌─────────────────────────────────────────┐ - │ 호스트:3000 ──▶ 컨테이너:3000 (Frontend)│ - │ 호스트:8000 ──▶ 컨테이너:8000 (Backend) │ - └─────────────────────────────────────────┘ +╔═══════════════════════════════════════════════════════════════════════════════╗ +║ PostgreSQL (서버1) ║ +╠═══════════════════════════════════════════════════════════════════════════════╣ +║ docker exec -it postgres-primary psql -U admin -d autonet # DB 접속 ║ +║ \dt # 테이블 목록 ║ +║ \q # 종료 ║ +╚═══════════════════════════════════════════════════════════════════════════════╝ ``` --- -## 7. 롤백 절차 +## 11. 체크리스트 -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ 롤백 절차 │ -└─────────────────────────────────────────────────────────────────────────────┘ +### 최초 서버 설정 - 문제 발생! - │ - ▼ - ┌───────────────┐ 아니오 ┌───────────────┐ - │ 자동 롤백 │ ◀────────────── │ 헬스체크 통과?│ - │ 실행됨 │ └───────────────┘ - └───────┬───────┘ │ 예 - │ ▼ - │ ┌───────────────┐ - │ │ 배포 완료 │ - │ └───────────────┘ - ▼ - ┌───────────────────────────────────────────────────────────────────────┐ - │ │ - │ /opt/autonet/releases/ │ - │ │ │ - │ ├── 20241230_140000/ ◀── 이전 버전으로 복원 │ - │ ├── 20241230_150000/ │ - │ └── 20241230_160000/ ◀── 현재 (문제 발생) │ - │ │ - └───────────────────────────────────────────────────────────────────────┘ +- [ ] 서버1: PostgreSQL 컨테이너 실행 중 +- [ ] 서버1: autonet 데이터베이스 생성 +- [ ] 서버1: 외부 접속 허용 (pg_hba.conf) +- [ ] 서버1: Nginx Proxy Manager SSL 설정 +- [ ] 서버1: Custom Location (/api, /uploads) 설정 +- [ ] 서버2: Docker Compose v2 설치 +- [ ] 서버2: backend/.env PostgreSQL 설정 +- [ ] 서버2: requirements.txt에 psycopg2-binary 추가 +- [ ] 서버2: config.py URL 인코딩 적용 - 롤백 명령어: - ┌─────────────────────────────────────────┐ - │ # 직전 버전으로 롤백 │ - │ /opt/autonet/scripts/deploy.sh rollback │ - │ │ - │ # 특정 버전으로 롤백 │ - │ /opt/autonet/scripts/deploy.sh \ │ - │ rollback-to 20241230_140000 │ - │ │ - │ # 사용 가능한 버전 확인 │ - │ ls /opt/autonet/releases/ │ - └─────────────────────────────────────────┘ -``` +### 배포 전 ---- +- [ ] 로컬 테스트 완료 +- [ ] git status로 변경 파일 확인 +- [ ] 커밋 메시지 작성 -## 8. 빠른 참조 명령어 +### 배포 후 -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ 빠른 참조 명령어 │ -└─────────────────────────────────────────────────────────────────────────────┘ - - ╔═══════════════════════════════════════════════════════════════════════════╗ - ║ 개발 (서버4) ║ - ╠═══════════════════════════════════════════════════════════════════════════╣ - ║ git status # 변경 파일 확인 ║ - ║ git add . # 스테이징 ║ - ║ git commit -m "message" # 커밋 ║ - ║ git push staging main # 서버2로 푸시 ║ - ╚═══════════════════════════════════════════════════════════════════════════╝ - - ╔═══════════════════════════════════════════════════════════════════════════╗ - ║ 운영 (서버2) ║ - ╠═══════════════════════════════════════════════════════════════════════════╣ - ║ docker ps # 실행 중인 컨테이너 확인 ║ - ║ docker logs autonet-frontend # 프론트엔드 로그 ║ - ║ docker logs autonet-backend # 백엔드 로그 ║ - ║ docker restart autonet-frontend # 프론트엔드 재시작 ║ - ║ docker restart autonet-backend # 백엔드 재시작 ║ - ╚═══════════════════════════════════════════════════════════════════════════╝ - - ╔═══════════════════════════════════════════════════════════════════════════╗ - ║ 배포 스크립트 ║ - ╠═══════════════════════════════════════════════════════════════════════════╣ - ║ ./deploy.sh promote # 스테이징 → 운영 승격 ║ - ║ ./deploy.sh rollback # 직전 버전 롤백 ║ - ║ ./deploy.sh rollback-to # 특정 버전 롤백 ║ - ║ ./deploy.sh status # 현재 상태 확인 ║ - ╚═══════════════════════════════════════════════════════════════════════════╝ -``` - ---- - -## 9. 체크리스트 - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ 배포 전 체크리스트 │ -└─────────────────────────────────────────────────────────────────────────────┘ - - 배포 전: - ┌─────────────────────────────────────────────────────────────────────────┐ - │ [ ] 로컬에서 테스트 완료 │ - │ [ ] git status로 변경 파일 확인 │ - │ [ ] 불필요한 파일 제외 (.env, node_modules 등) │ - │ [ ] 커밋 메시지 작성 │ - └─────────────────────────────────────────────────────────────────────────┘ - - 스테이징 테스트: - ┌─────────────────────────────────────────────────────────────────────────┐ - │ [ ] Frontend 정상 작동 (http://192.168.0.202:3001) │ - │ [ ] Backend API 정상 작동 (http://192.168.0.202:8001/docs) │ - │ [ ] 주요 기능 테스트 완료 │ - │ [ ] 에러 로그 확인 (docker logs) │ - └─────────────────────────────────────────────────────────────────────────┘ - - 운영 배포 후: - ┌─────────────────────────────────────────────────────────────────────────┐ - │ [ ] 헬스체크 통과 │ - │ [ ] 주요 페이지 접속 확인 │ - │ [ ] API 응답 확인 │ - │ [ ] 이전 버전 백업 확인 (/opt/autonet/releases/) │ - └─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 10. 문제 해결 - -| 문제 | 해결 방법 | -|------|----------| -| 컨테이너 시작 안됨 | `docker logs ` 로그 확인 | -| 포트 충돌 | `netstat -tlnp \| grep ` 확인 후 프로세스 종료 | -| 이미지 빌드 실패 | `docker build --no-cache` 캐시 없이 재빌드 | -| DB 연결 오류 | Volume 마운트 확인, 파일 권한 확인 | -| API 404 오류 | Backend 컨테이너 재시작, 라우터 등록 확인 | +- [ ] docker ps로 컨테이너 상태 확인 +- [ ] docker logs로 에러 확인 +- [ ] https://autonetsellcar.com 접속 테스트 +- [ ] API 응답 확인 (/api/docs) --- diff --git a/backend/Dockerfile b/backend/Dockerfile index eb3faa5..5c6bd6a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,16 +2,37 @@ FROM python:3.11-slim WORKDIR /app -# Install system dependencies +# Install system dependencies for PostgreSQL and Playwright RUN apt-get update && apt-get install -y \ gcc \ libpq-dev \ + # Playwright dependencies + libnss3 \ + libnspr4 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libdbus-1-3 \ + libxkbcommon0 \ + libatspi2.0-0 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxrandr2 \ + libgbm1 \ + libasound2 \ + libpango-1.0-0 \ + libcairo2 \ && rm -rf /var/lib/apt/lists/* # Install Python dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +# Install Playwright browsers (Chromium only for smaller image) +RUN playwright install chromium + # Copy application code COPY . . diff --git a/backend/app/api/hero_banners.py b/backend/app/api/hero_banners.py index e1294a5..604b5e7 100644 --- a/backend/app/api/hero_banners.py +++ b/backend/app/api/hero_banners.py @@ -40,7 +40,7 @@ def get_localized_field(obj, field: str, lang: str) -> Optional[str]: @router.get("/", response_model=List[HeroBannerLocalizedResponse]) def get_hero_banners( - lang: str = Query("ko", regex="^(ko|en|mn|ru)$"), + lang: str = Query("en", regex="^(ko|en|mn|ru)$"), db: Session = Depends(get_db) ): """활성 히어로 배너 목록 조회 (Public)""" diff --git a/backend/app/config.py b/backend/app/config.py index aa837be..cff6ee6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -64,7 +64,10 @@ class Settings(BaseSettings): base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) db_path = os.path.join(base_dir, "autonet.db") return f"sqlite:///{db_path}" - return f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + # URL-encode password for special characters like @ # etc + from urllib.parse import quote_plus + encoded_password = quote_plus(self.DB_PASSWORD) + return f"postgresql://{self.DB_USER}:{encoded_password}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" @property def REDIS_URL(self) -> str: diff --git a/backend/app/main.py b/backend/app/main.py index 5f20e79..efdfe4b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,8 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import RedirectResponse from contextlib import asynccontextmanager import os import asyncio @@ -15,6 +17,42 @@ from datetime import datetime, timedelta app_settings = get_settings() + +class TrailingSlashMiddleware(BaseHTTPMiddleware): + """ + Middleware to normalize trailing slashes on API paths. + Uses redirect to strip trailing slashes from non-root routes. + + Routes defined with "/" (like /hero-banners/) keep trailing slash. + Routes defined without "/" (like /cars) get redirected. + """ + # Routes that are defined WITH trailing slash (router.get("/")) + TRAILING_SLASH_ROUTES = { + "/api/hero-banners/", + "/api/settings/", + "/api/notifications/", + "/api/vehicle-requests/", + } + + async def dispatch(self, request: Request, call_next): + path = request.url.path + + # Skip if it's a known trailing-slash route + if path in self.TRAILING_SLASH_ROUTES: + return await call_next(request) + + # Redirect trailing slash from other /api/* paths + if path.startswith("/api/") and path.endswith("/") and len(path) > 5: + new_path = path.rstrip("/") + if request.url.query: + new_url = f"{new_path}?{request.url.query}" + else: + new_url = new_path + return RedirectResponse(url=new_url, status_code=307) + + return await call_next(request) + + # Create tables Base.metadata.create_all(bind=engine) @@ -118,6 +156,9 @@ app = FastAPI( lifespan=lifespan ) +# Trailing slash middleware (must be added before CORS) +app.add_middleware(TrailingSlashMiddleware) + # CORS - credentials=True requires explicit origins (not "*") app.add_middleware( CORSMiddleware, @@ -126,6 +167,8 @@ app.add_middleware( "http://127.0.0.1:3000", "http://localhost:8000", "http://192.168.0.202:3000", # Local network + "https://autonetsellcar.com", # Production + "http://autonetsellcar.com", # Production (HTTP redirect) ], allow_credentials=True, allow_methods=["*"], diff --git a/backend/fix_banner_translations.py b/backend/fix_banner_translations.py new file mode 100644 index 0000000..3838d1a --- /dev/null +++ b/backend/fix_banner_translations.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Fix hero banner translations by updating title_en, title_mn, title_ru from title_ko +""" +import sqlite3 +import os + +# Translation dictionary (subset of key terms) +TRANSLATIONS = { + # Makers + '현대': {'en': 'Hyundai', 'mn': 'Хёндай', 'ru': 'Хёндай'}, + '기아': {'en': 'Kia', 'mn': 'Киа', 'ru': 'Киа'}, + '제네시스': {'en': 'Genesis', 'mn': 'Женезис', 'ru': 'Дженезис'}, + '쉐보레': {'en': 'Chevrolet', 'mn': 'Шевроле', 'ru': 'Шевроле'}, + 'KG모빌리티': {'en': 'KG Mobility', 'mn': 'КЖ Мобилити', 'ru': 'КГ Мобилити'}, + '쌍용': {'en': 'SsangYong', 'mn': 'СсангЁнг', 'ru': 'СсангЙонг'}, + + # Models + '모하비': {'en': 'Mohave', 'mn': 'Мохаве', 'ru': 'Мохаве'}, + '더 마스터': {'en': 'The Master', 'mn': 'Мастер', 'ru': 'Мастер'}, + '신형': {'en': 'New', 'mn': 'Шинэ', 'ru': 'Новый'}, + '더 뉴': {'en': 'The New', 'mn': 'Шинэ', 'ru': 'Новый'}, + '그랜드스타렉스': {'en': 'Grand Starex', 'mn': 'Гранд Старекс', 'ru': 'Гранд Старекс'}, + '스타렉스': {'en': 'Starex', 'mn': 'Старекс', 'ru': 'Старекс'}, + '싼타페': {'en': 'Santa Fe', 'mn': 'Санта Фе', 'ru': 'Санта Фе'}, + '스팅어': {'en': 'Stinger', 'mn': 'Стингер', 'ru': 'Стингер'}, + '마이스터': {'en': 'Meister', 'mn': 'Мейстер', 'ru': 'Мейстер'}, + '쏘렌토': {'en': 'Sorento', 'mn': 'Соренто', 'ru': 'Соренто'}, + '스포티지': {'en': 'Sportage', 'mn': 'Спортаж', 'ru': 'Спортаж'}, + '카니발': {'en': 'Carnival', 'mn': 'Карнивал', 'ru': 'Карнивал'}, + '셀토스': {'en': 'Seltos', 'mn': 'Селтос', 'ru': 'Селтос'}, + '투싼': {'en': 'Tucson', 'mn': 'Туксон', 'ru': 'Туксон'}, + '팰리세이드': {'en': 'Palisade', 'mn': 'Палисейд', 'ru': 'Палисейд'}, + '아반떼': {'en': 'Avante', 'mn': 'Аванте', 'ru': 'Аванте'}, + '쏘나타': {'en': 'Sonata', 'mn': 'Соната', 'ru': 'Соната'}, + '그랜저': {'en': 'Grandeur', 'mn': 'Грандёр', 'ru': 'Грандёр'}, + '코나': {'en': 'Kona', 'mn': 'Кона', 'ru': 'Кона'}, + 'K5': {'en': 'K5', 'mn': 'K5', 'ru': 'K5'}, + 'K3': {'en': 'K3', 'mn': 'K3', 'ru': 'K3'}, + 'K7': {'en': 'K7', 'mn': 'K7', 'ru': 'K7'}, + 'K8': {'en': 'K8', 'mn': 'K8', 'ru': 'K8'}, + 'K9': {'en': 'K9', 'mn': 'K9', 'ru': 'K9'}, + 'GV70': {'en': 'GV70', 'mn': 'GV70', 'ru': 'GV70'}, + 'GV80': {'en': 'GV80', 'mn': 'GV80', 'ru': 'GV80'}, + 'G70': {'en': 'G70', 'mn': 'G70', 'ru': 'G70'}, + 'G80': {'en': 'G80', 'mn': 'G80', 'ru': 'G80'}, + 'G90': {'en': 'G90', 'mn': 'G90', 'ru': 'G90'}, +} + +# Sort keys by length (longest first) to avoid partial matches +SORTED_KEYS = sorted(TRANSLATIONS.keys(), key=len, reverse=True) + + +def translate(text: str, lang: str) -> str: + """Translate Korean text to target language""" + if not text: + return text + + result = text + for key in SORTED_KEYS: + if key in result: + translation = TRANSLATIONS[key].get(lang, key) + result = result.replace(key, translation) + return result + + +def main(): + # Find database file + db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "autonet.db") + + if not os.path.exists(db_path): + print(f"Database not found at: {db_path}") + return + + print(f"Using database: {db_path}") + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Get all banners + cursor.execute("SELECT id, title_ko, title_en, title_mn, title_ru FROM hero_banners") + banners = cursor.fetchall() + + print(f"\nFound {len(banners)} banners to update:\n") + + for banner in banners: + banner_id, title_ko, title_en, title_mn, title_ru = banner + + if not title_ko: + print(f"Banner {banner_id}: No Korean title, skipping") + continue + + new_title_en = translate(title_ko, 'en') + new_title_mn = translate(title_ko, 'mn') + new_title_ru = translate(title_ko, 'ru') + + print(f"Banner {banner_id}:") + print(f" KO: {title_ko}") + print(f" EN: {title_en} -> {new_title_en}") + print(f" MN: {title_mn} -> {new_title_mn}") + print(f" RU: {title_ru} -> {new_title_ru}") + print() + + # Update the banner + cursor.execute(""" + UPDATE hero_banners + SET title_en = ?, title_mn = ?, title_ru = ? + WHERE id = ? + """, (new_title_en, new_title_mn, new_title_ru, banner_id)) + + conn.commit() + print(f"Updated {len(banners)} banners successfully!") + + # Verify + print("\n--- Verification ---") + cursor.execute("SELECT id, title_ko, title_en, title_mn FROM hero_banners") + for row in cursor.fetchall(): + print(f"ID {row[0]}: {row[1]} -> EN: {row[2]}, MN: {row[3]}") + + conn.close() + + +if __name__ == "__main__": + main() diff --git a/backend/migrate_to_postgres.py b/backend/migrate_to_postgres.py new file mode 100644 index 0000000..3961c41 --- /dev/null +++ b/backend/migrate_to_postgres.py @@ -0,0 +1,297 @@ +""" +SQLite to PostgreSQL Migration Script +Handles boolean conversion and foreign key constraints +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import sqlite3 +import psycopg2 +from urllib.parse import quote_plus + +SQLITE_PATH = os.path.join(os.path.dirname(__file__), "autonet.db") +PG_CONFIG = { + "host": "192.168.0.201", + "port": 5432, + "database": "autonet", + "user": "admin", + "password": "roskfl@1122" +} + +# Tables in dependency order (parents first) +TABLES_ORDER = [ + # Base tables (no FK dependencies) + "car_makers", + "car_models", + "translations", + "system_settings", + "cc_packages", + "exchange_rates", + "exchange_rate_history", + "hero_banner_settings", + # Users before user-dependent tables + "users", + # Car related + "cars", + "car_images", + "car_options", + "car_performance_checks", + "car_specifications", + "car_views", + "performance_check_views", + # Cache + "car_cache", + "car_detail_cache", + "cache_request_queue", + # Hero banners + "hero_banners", + # User activities + "charge_history", + "inquiries", + "inquiry_messages", + "vehicle_requests", + "request_vehicles", + "purchased_vehicles", + "dealer_applications", + "dealer_info", + "vehicle_shares", + "share_rewards", + "withdrawal_requests", + "referral_rewards", + "notifications", + "push_subscriptions", + "user_notification_preferences", + "verification_codes", + "visitor_logs", + "visitor_daily_stats", + "visitor_sessions", +] + +def create_tables(): + """Create tables in PostgreSQL""" + print("\n[Step 1] Creating tables in PostgreSQL...") + + os.environ["USE_SQLITE"] = "False" + os.environ["DB_HOST"] = PG_CONFIG["host"] + os.environ["DB_PORT"] = str(PG_CONFIG["port"]) + os.environ["DB_NAME"] = PG_CONFIG["database"] + os.environ["DB_USER"] = PG_CONFIG["user"] + os.environ["DB_PASSWORD"] = PG_CONFIG["password"] + + from sqlalchemy import create_engine + from app.database import Base + from app.models import ( + CarMaker, CarModel, Car, CarImage, CarOption, + User, CarView, PerformanceCheckView, ChargeHistory, VerificationCode, + Inquiry, InquiryMessage, HeroBanner, HeroBannerSettings, + Translation, CarCache, CarDetailCache, CacheRequestQueue, + SystemSettings, VehicleRequest, RequestVehicle, PurchasedVehicle, + DealerApplication, DealerInfo, VehicleShare, ShareReward, + WithdrawalRequest, ReferralReward, Notification, + PushSubscription, UserNotificationPreference, + CarPerformanceCheck, CarSpecification, + ExchangeRate, ExchangeRateHistory, CCPackage, + VisitorLog, VisitorDailyStats, VisitorSession, + ) + + encoded_pw = quote_plus(PG_CONFIG['password']) + pg_url = f"postgresql://{PG_CONFIG['user']}:{encoded_pw}@{PG_CONFIG['host']}:{PG_CONFIG['port']}/{PG_CONFIG['database']}" + engine = create_engine(pg_url, echo=False) + Base.metadata.create_all(bind=engine) + print(" Tables created successfully!") + +def get_boolean_columns(pg_cursor, table_name): + """Get list of boolean columns for a table""" + pg_cursor.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = %s AND data_type = 'boolean' + """, (table_name,)) + return [row[0] for row in pg_cursor.fetchall()] + +def convert_row(row, columns, bool_cols): + """Convert SQLite row values for PostgreSQL (handle booleans)""" + result = [] + for i, val in enumerate(row): + col_name = columns[i] + if col_name in bool_cols and val is not None: + # Convert 0/1 to False/True + result.append(bool(val)) + else: + result.append(val) + return tuple(result) + +def migrate_table(sqlite_conn, pg_conn, table_name): + """Migrate a single table""" + sqlite_cursor = sqlite_conn.cursor() + pg_cursor = pg_conn.cursor() + + # Get SQLite columns + sqlite_cursor.execute(f"PRAGMA table_info({table_name})") + sqlite_cols = [col[1] for col in sqlite_cursor.fetchall()] + if not sqlite_cols: + return 0 + + # Get data + sqlite_cursor.execute(f"SELECT * FROM {table_name}") + rows = sqlite_cursor.fetchall() + if not rows: + print(f" {table_name}: 0 rows (empty)") + return 0 + + # Check PostgreSQL table exists + pg_cursor.execute(""" + SELECT EXISTS (SELECT FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = %s) + """, (table_name,)) + if not pg_cursor.fetchone()[0]: + print(f" {table_name}: skipped (not in PostgreSQL)") + return 0 + + # Get PostgreSQL columns + pg_cursor.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = %s + ORDER BY ordinal_position + """, (table_name,)) + pg_cols = [row[0] for row in pg_cursor.fetchall()] + + # Find common columns + common_cols = [c for c in sqlite_cols if c in pg_cols] + if not common_cols: + print(f" {table_name}: skipped (no matching columns)") + return 0 + + col_indices = [sqlite_cols.index(c) for c in common_cols] + + # Get boolean columns + bool_cols = set(get_boolean_columns(pg_cursor, table_name)) + + # Prepare query + cols_str = ", ".join(common_cols) + placeholders = ", ".join(["%s"] * len(common_cols)) + insert_sql = f"INSERT INTO {table_name} ({cols_str}) VALUES ({placeholders})" + + try: + # Truncate with CASCADE to handle FK + pg_cursor.execute(f"TRUNCATE TABLE {table_name} CASCADE") + + success = 0 + for row in rows: + try: + # Extract and convert row + filtered = tuple(row[i] for i in col_indices) + converted = convert_row(filtered, common_cols, bool_cols) + pg_cursor.execute(insert_sql, converted) + success += 1 + except Exception as e: + pg_conn.rollback() + # Truncate again after rollback + pg_cursor.execute(f"TRUNCATE TABLE {table_name} CASCADE") + print(f" {table_name}: error - {str(e)[:80]}") + return 0 + + pg_conn.commit() + print(f" {table_name}: {success}/{len(rows)} rows migrated") + return success + except Exception as e: + pg_conn.rollback() + print(f" {table_name}: error - {str(e)[:80]}") + return 0 + +def reset_sequences(pg_conn): + """Reset sequences to max(id) + 1""" + print("\n[Step 3] Resetting sequences...") + pg_cursor = pg_conn.cursor() + + # Get all tables with id column + pg_cursor.execute(""" + SELECT table_name FROM information_schema.columns + WHERE table_schema = 'public' AND column_name = 'id' + """) + tables = [row[0] for row in pg_cursor.fetchall()] + + for table in tables: + try: + # Check if sequence exists + seq_name = f"{table}_id_seq" + pg_cursor.execute(""" + SELECT EXISTS (SELECT FROM pg_sequences WHERE schemaname = 'public' AND sequencename = %s) + """, (seq_name,)) + if pg_cursor.fetchone()[0]: + pg_cursor.execute(f""" + SELECT setval('{seq_name}', COALESCE((SELECT MAX(id) FROM {table}), 0) + 1, false) + """) + except: + pass + + pg_conn.commit() + print(" Sequences reset completed") + +def main(): + print("=" * 60) + print("SQLite to PostgreSQL Migration") + print("=" * 60) + + # Step 1: Create tables + try: + create_tables() + except Exception as e: + print(f" Failed: {e}") + sys.exit(1) + + # Step 2: Connect and migrate + print("\n[Step 2] Migrating data...") + + sqlite_conn = sqlite3.connect(SQLITE_PATH) + + encoded_pw = quote_plus(PG_CONFIG['password']) + pg_conn = psycopg2.connect( + host=PG_CONFIG['host'], + port=PG_CONFIG['port'], + database=PG_CONFIG['database'], + user=PG_CONFIG['user'], + password=PG_CONFIG['password'] + ) + + # Disable FK checks during migration + pg_cursor = pg_conn.cursor() + pg_cursor.execute("SET session_replication_role = 'replica';") + pg_conn.commit() + + # Get SQLite tables + sqlite_cursor = sqlite_conn.cursor() + sqlite_cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name != 'sqlite_sequence'") + all_tables = set(t[0] for t in sqlite_cursor.fetchall()) + + # Migrate in order + total = 0 + migrated_tables = set() + + for table in TABLES_ORDER: + if table in all_tables: + total += migrate_table(sqlite_conn, pg_conn, table) + migrated_tables.add(table) + + # Migrate remaining tables + remaining = all_tables - migrated_tables + for table in remaining: + total += migrate_table(sqlite_conn, pg_conn, table) + + # Re-enable FK checks + pg_cursor.execute("SET session_replication_role = 'origin';") + pg_conn.commit() + + # Step 3: Reset sequences + reset_sequences(pg_conn) + + sqlite_conn.close() + pg_conn.close() + + print("\n" + "=" * 60) + print(f"Migration completed! Total rows: {total}") + print("=" * 60) + +if __name__ == "__main__": + main() diff --git a/backend/requirements.txt b/backend/requirements.txt index 3abb92c..a8bd45b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,7 +1,7 @@ fastapi uvicorn[standard] sqlalchemy -# psycopg2-binary # Uncomment for PostgreSQL production +psycopg2-binary # PostgreSQL production redis python-dotenv pydantic @@ -15,6 +15,8 @@ lxml alembic email-validator playwright # PDF capture for performance check reports +img2pdf # Convert screenshots to PDF +pillow # Image processing for PDF generation apscheduler # Scheduled tasks (exchange rate updates) stripe # Payment processing user-agents # Visitor tracking diff --git a/frontend/src/app/admin/cars/page.tsx b/frontend/src/app/admin/cars/page.tsx index c4d5bdf..58c1b47 100644 --- a/frontend/src/app/admin/cars/page.tsx +++ b/frontend/src/app/admin/cars/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import Image from 'next/image'; import api, { heroBannersApi, carmodooApi, vehicleRequestsApi } from '@/lib/api'; +import { translateCarName } from '@/lib/i18n'; interface CarmodooMaker { code: string; @@ -130,6 +131,8 @@ export default function CarsAdminPage() { const [selectedCar, setSelectedCar] = useState(null); const [showDetailModal, setShowDetailModal] = useState(false); const [currentImageIndex, setCurrentImageIndex] = useState(0); + const [selectedLocalCars, setSelectedLocalCars] = useState>(new Set()); + const [registeringLocalBanner, setRegisteringLocalBanner] = useState(false); // All Cars (public view) state const [allCars, setAllCars] = useState([]); @@ -549,30 +552,6 @@ export default function CarsAdminPage() { } }; - // 차량명 번역 함수 - const translateCarName = (koreanName: string | undefined): string => { - if (!koreanName) return '-'; - - const translations: Record = { - '현대': 'Hyundai', '제네시스': 'Genesis', '기아': 'Kia', - '쉐보레(대우)': 'Chevrolet', '쉐보레': 'Chevrolet', - '르노(삼성)': 'Renault', 'KG모빌리티(쌍용)': 'KG Mobility', - '닛산': 'Nissan', '렉서스': 'Lexus', '토요타': 'Toyota', '혼다': 'Honda', - '쏘렌토': 'Sorento', '스포티지': 'Sportage', '셀토스': 'Seltos', - '카니발': 'Carnival', '모닝': 'Morning', '레이': 'Ray', - '아반떼': 'Avante', '쏘나타': 'Sonata', '그랜저': 'Grandeur', - '투싼': 'Tucson', '싼타페': 'Santa Fe', '팰리세이드': 'Palisade', - '코나': 'Kona', '스타리아': 'Staria', '캐스퍼': 'Casper', - }; - - let result = koreanName; - const sortedKeys = Object.keys(translations).sort((a, b) => b.length - a.length); - for (const korean of sortedKeys) { - result = result.replace(new RegExp(korean, 'g'), translations[korean]); - } - return result; - }; - // 딜러 설명 미리보기 및 편집 함수 const handleEditDealerDescription = async (car: CarmodooCarItem) => { setEditingCar(car); @@ -700,11 +679,13 @@ export default function CarsAdminPage() { const bannerData = { title_ko: car.car_name || '', - title_en: translateCarName(car.car_name), - title_mn: translateCarName(car.car_name), + title_en: translateCarName(car.car_name, 'en'), + title_mn: translateCarName(car.car_name, 'mn'), + title_ru: translateCarName(car.car_name, 'ru'), subtitle_ko: `${car.year}년식 | ${car.mileage?.toLocaleString()}km`, subtitle_en: `${car.year} | ${car.mileage?.toLocaleString()}km`, subtitle_mn: `${car.year} | ${car.mileage?.toLocaleString()}km`, + subtitle_ru: `${car.year} | ${car.mileage?.toLocaleString()}km`, image_url: localImageUrl, link_url: `/cars/${carId}`, is_active: true, @@ -752,6 +733,80 @@ export default function CarsAdminPage() { } }; + // Local Cars에서 배너 등록하는 함수 + const handleRegisterLocalCarAsBanner = async () => { + if (selectedLocalCars.size === 0) { + alert('Please select at least one car to register as banner.'); + return; + } + + if (!confirm(`${selectedLocalCars.size}개의 차량을 Hero Banner로 등록하시겠습니까?`)) { + return; + } + + setRegisteringLocalBanner(true); + try { + const selectedCarsList = localCars.filter(car => selectedLocalCars.has(car.id)); + const existingBanners = await heroBannersApi.adminGetList(); + let orderStart = existingBanners.length; + let successCount = 0; + + for (const car of selectedCarsList) { + const localImageUrl = `/uploads/cars/${car.id}/image_0.jpg`; + + const bannerData = { + title_ko: car.car_name || '', + title_en: translateCarName(car.car_name || '', 'en'), + title_mn: translateCarName(car.car_name || '', 'mn'), + title_ru: translateCarName(car.car_name || '', 'ru'), + subtitle_ko: `${car.year || ''}년식 | ${car.mileage ? formatMileage(car.mileage) : ''}`, + subtitle_en: `${car.year || ''} | ${car.mileage ? formatMileage(car.mileage) : ''}`, + subtitle_mn: `${car.year || ''} | ${car.mileage ? formatMileage(car.mileage) : ''}`, + subtitle_ru: `${car.year || ''} | ${car.mileage ? formatMileage(car.mileage) : ''}`, + image_url: localImageUrl, + link_url: `/cars/${car.id}`, + display_order: orderStart++, + is_active: true, + car_id: car.id, + }; + + await heroBannersApi.adminCreate(bannerData); + successCount++; + } + + alert(`${successCount}개의 차량이 Hero Banner로 등록되었습니다.`); + setSelectedLocalCars(new Set()); + } catch (err) { + console.error('Local banner registration failed:', err); + alert('배너 등록에 실패했습니다.'); + } finally { + setRegisteringLocalBanner(false); + } + }; + + // Local car selection toggle + const handleLocalCarSelect = (carId: number, e: React.MouseEvent) => { + e.stopPropagation(); + setSelectedLocalCars(prev => { + const newSet = new Set(prev); + if (newSet.has(carId)) { + newSet.delete(carId); + } else { + newSet.add(carId); + } + return newSet; + }); + }; + + // Select all local cars + const handleSelectAllLocalCars = () => { + if (selectedLocalCars.size === localCars.length) { + setSelectedLocalCars(new Set()); + } else { + setSelectedLocalCars(new Set(localCars.map(car => car.id))); + } + }; + // 차량 추천 목록에 추가 함수 (Vehicle Request용) const handleAddToRequest = async () => { if (!requestId) return; @@ -1029,17 +1084,45 @@ export default function CarsAdminPage() {

Imported Cars ({localTotal} total) + {selectedLocalCars.size > 0 && ( + + ({selectedLocalCars.size} selected) + + )}

- +
+ {selectedLocalCars.size > 0 && ( + + )} + +
{localLoading ? ( @@ -1066,6 +1149,14 @@ export default function CarsAdminPage() { + @@ -1088,6 +1179,24 @@ export default function CarsAdminPage() { className={`border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${!car.is_displayed ? 'opacity-60' : ''}`} onClick={() => handleCarClick(car)} > +
+ 0} + onChange={handleSelectAllLocalCars} + className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500" + /> + Display Image Car Name e.stopPropagation()}> + { + setSelectedLocalCars(prev => { + const newSet = new Set(prev); + if (newSet.has(car.id)) { + newSet.delete(car.id); + } else { + newSet.add(car.id); + } + return newSet; + }); + }} + className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500" + /> +