fix: banner translations and deployment improvements

- Add translateCarName import from i18n.ts for proper multilingual support
- Change default API language from 'ko' to 'en' for hero banners
- Add checkbox column for Local Cars banner registration
- Update Dockerfile with Playwright dependencies
- Add PostgreSQL migration script
- Add banner translation fix script

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
AutonetSellCar Deploy
2025-12-31 10:41:42 +09:00
parent 898ab3a0eb
commit e661d91c72
10 changed files with 1145 additions and 490 deletions

View File

@@ -1,15 +1,326 @@
# AutonetSellCar.com 배포 가이드 # 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. 운영 │ │ 1. 개발 │ ───▶ │ 2. 커밋 │ ───▶ │ 3. 스테이징 │ ───▶ │ 4. 운영 │
@@ -18,38 +329,63 @@
│ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
코드 수정 git commit Docker 빌드 promote 코드 수정 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. 서버 환경 ## 7. 디렉토리
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 네트워크 구성도 │
└─────────────────────────────────────────────────────────────────────────────┘
┌────────────────────┐ ┌────────────────────────────────┐
│ 서버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. 디렉토리 구조
``` ```
서버2 (/opt/autonet/) 서버2 (/opt/autonet/)
@@ -62,11 +398,17 @@
├── staging/ # 스테이징 환경 ├── staging/ # 스테이징 환경
│ ├── frontend/ │ ├── frontend/
│ ├── backend/ │ ├── backend/
│ │ └── .env # 스테이징 환경변수
│ └── docker-compose.staging.yml │ └── docker-compose.staging.yml
├── production/ # 운영 환경 ├── production/ # 운영 환경
│ ├── frontend/ │ ├── frontend/
│ ├── backend/ │ ├── backend/
│ │ ├── .env # 운영 환경변수 (PostgreSQL)
│ │ ├── requirements.txt # psycopg2-binary 포함
│ │ └── app/
│ │ └── config.py # URL 인코딩 적용
│ ├── carmodoo-agent/
│ └── docker-compose.production.yml │ └── docker-compose.production.yml
├── releases/ # 롤백용 백업 ├── releases/ # 롤백용 백업
@@ -80,445 +422,159 @@
--- ---
## 4. 단계별 상세 ## 8. 환경변수
### Step 1: 코드 수정 (서버4) ### 운영 서버 backend/.env 전체
``` ```env
┌─────────────────────────────────────────────────────────────────────────────┐ # Database (PostgreSQL)
│ Step 1: 코드 수정 │ USE_SQLITE=False
└─────────────────────────────────────────────────────────────────────────────┘ DB_HOST=192.168.0.201
DB_PORT=5432
DB_NAME=autonet
DB_USER=admin
DB_PASSWORD=roskfl@1122
개발자 PC (서버4) # Redis
┌─────────────────────────────────────────┐ REDIS_HOST=192.168.0.201
│ │ REDIS_PORT=6379
│ D:\Workspace\claudeCode\ │ REDIS_PASSWORD=
│ └── AutonetSellCar.com\ │
│ ├── backend\ │
│ │ └── app\ │
│ │ ├── api\ ◀── 수정 │
│ │ ├── models\ │
│ │ └── schemas\ ◀── 수정 │
│ │ │
│ └── frontend\ │
│ └── src\ │
│ ├── app\ ◀── 수정 │
│ ├── components\ ◀── 수정 │
│ └── lib\ ◀── 수정 │
│ │
│ ┌─────────────────────────────────┐ │
│ │ 로컬 테스트 서버 │ │
│ │ Frontend: http://localhost:3000│ │
│ │ Backend: http://localhost:8000│ │
│ └─────────────────────────────────┘ │
│ │
└─────────────────────────────────────────┘
명령어: # JWT
┌─────────────────────────────────────────┐ SECRET_KEY=your-secret-key-change-in-production
│ # Backend 실행 │
│ cd backend │
│ venv\Scripts\activate │
│ uvicorn app.main:app --reload --port 8000│
│ │
│ # Frontend 실행 │
│ cd frontend │
│ npm run dev │
└─────────────────────────────────────────┘
```
### Step 2: Git Commit & Push # App
DEBUG=False
``` # Azure Translator
┌─────────────────────────────────────────────────────────────────────────────┐ AZURE_TRANSLATOR_KEY=your-azure-key
│ Step 2: Git Commit & Push │ AZURE_TRANSLATOR_REGION=southeastasia
└─────────────────────────────────────────────────────────────────────────────┘
┌───────────────┐ ┌───────────────┐ # Email (SMTP)
│ 서버4 │ │ 서버2 │ SMTP_HOST=smtp.gmail.com
│ (개발) │ │ (운영) │ SMTP_PORT=587
└───────┬───────┘ └───────┬───────┘ SMTP_USER=autonetsellcar@gmail.com
│ │ SMTP_PASSWORD=your-app-password
│ 1. git add . │ SMTP_FROM_EMAIL=autonetsellcar@gmail.com
│ 2. git commit -m "message" │ SMTP_FROM_NAME=AutonetSellCar
│ │
▼ │
┌───────────────┐ │
│ Local Repo │ │
│ (main) │ │
└───────┬───────┘ │
│ │
│ 3. git push staging main │
│ │
│ SSH (포트 22) │
│ ════════════════════════════════════▶ │
│ ▼
│ ┌───────────────┐
│ │ Bare Repo │
│ │ autonet.git │
│ └───────┬───────┘
│ │
│ │ post-receive 훅 실행
│ ▼
│ ┌───────────────┐
│ │ 스테이징 │
│ │ 자동 배포 │
│ └───────────────┘
명령어: # Verification
┌─────────────────────────────────────────┐ VERIFICATION_CODE_EXPIRE_MINUTES=10
│ git status │ EMAIL_VERIFICATION_REQUIRED=True
│ 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 │
└─────────────────────────────────────────┘
``` ```
--- ---
## 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 에러
``` ```
┌─────────────────────────────────────────────────────────────────────────────┐ # 에러: Mixed Content - HTTPS 페이지에서 HTTP API 호출
│ 수동 배포 (SCP 방식) │ # 해결: Frontend 빌드 시 HTTPS API URL 사용
└─────────────────────────────────────────────────────────────────────────────┘ NEXT_PUBLIC_API_URL=https://autonetsellcar.com
서버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 │
└─────────────────────────────────────────────────────────────────────┘
``` ```
--- ---
## 6. Docker 컨테이너 구성 ## 10. 빠른 참조 명령어
``` ```
┌─────────────────────────────────────────────────────────────────────────────┐ ╔═══════════════════════════════════════════════════════════════════════════════╗
│ Docker 컨테이너 구성도 │
└─────────────────────────────────────────────────────────────────────────────┘
운영 환경 (Production)
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Docker Network │ │
│ │ │ │
│ │ ┌──────────────────┐ ┌──────────────────┐ │ │
│ │ │ │ │ │ │ │
│ │ │ autonet-frontend│ API │ autonet-backend │ │ │
│ │ │ │───────▶│ │ │ │
│ │ │ Next.js 14 │ │ FastAPI │ │ │
│ │ │ Port: 3000 │ │ Port: 8000 │ │ │
│ │ │ │ │ │ │ │
│ │ └──────────────────┘ └────────┬─────────┘ │ │
│ │ │ │ │
│ │ ┌────────▼─────────┐ │ │
│ │ │ │ │ │
│ │ │ Volumes │ │ │
│ │ │ - autonet.db │ │ │
│ │ │ - uploads/ │ │ │
│ │ │ │ │ │
│ │ └──────────────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ carmodoo-agent │ (Playwright 브라우저 자동화) │
│ │ - PDF 생성 │ │
│ │ - 차량 스펙 조회 │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
포트 매핑:
┌─────────────────────────────────────────┐
│ 호스트:3000 ──▶ 컨테이너:3000 (Frontend)│
│ 호스트:8000 ──▶ 컨테이너:8000 (Backend) │
└─────────────────────────────────────────┘
```
---
## 7. 롤백 절차
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 롤백 절차 │
└─────────────────────────────────────────────────────────────────────────────┘
문제 발생!
┌───────────────┐ 아니오 ┌───────────────┐
│ 자동 롤백 │ ◀────────────── │ 헬스체크 통과?│
│ 실행됨 │ └───────────────┘
└───────┬───────┘ │ 예
│ ▼
│ ┌───────────────┐
│ │ 배포 완료 │
│ └───────────────┘
┌───────────────────────────────────────────────────────────────────────┐
│ │
│ /opt/autonet/releases/ │
│ │ │
│ ├── 20241230_140000/ ◀── 이전 버전으로 복원 │
│ ├── 20241230_150000/ │
│ └── 20241230_160000/ ◀── 현재 (문제 발생) │
│ │
└───────────────────────────────────────────────────────────────────────┘
롤백 명령어:
┌─────────────────────────────────────────┐
│ # 직전 버전으로 롤백 │
│ /opt/autonet/scripts/deploy.sh rollback │
│ │
│ # 특정 버전으로 롤백 │
│ /opt/autonet/scripts/deploy.sh \ │
│ rollback-to 20241230_140000 │
│ │
│ # 사용 가능한 버전 확인 │
│ ls /opt/autonet/releases/ │
└─────────────────────────────────────────┘
```
---
## 8. 빠른 참조 명령어
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 빠른 참조 명령어 │
└─────────────────────────────────────────────────────────────────────────────┘
╔═══════════════════════════════════════════════════════════════════════════╗
║ 개발 (서버4) ║ ║ 개발 (서버4) ║
╠═══════════════════════════════════════════════════════════════════════════╣ ═══════════════════════════════════════════════════════════════════════════════╣
║ git status # 변경 파일 확인 ║ ║ git status # 변경 파일 확인 ║
║ git add . # 스테이징 ║ ║ git add . # 스테이징 ║
║ git commit -m "message" # 커밋 ║ ║ git commit -m "message" # 커밋 ║
║ git push staging main # 서버2로 푸시 ║ ║ git push staging main # 서버2로 푸시 ║
╚═══════════════════════════════════════════════════════════════════════════╝ ═══════════════════════════════════════════════════════════════════════════════╝
╔═══════════════════════════════════════════════════════════════════════════╗ ═══════════════════════════════════════════════════════════════════════════════╗
║ 운영 (서버2) ║ ║ 운영 (서버2) ║
╠═══════════════════════════════════════════════════════════════════════════╣ ═══════════════════════════════════════════════════════════════════════════════╣
║ docker ps # 실행 중인 컨테이너 확인 ║ docker ps # 실행 중인 컨테이너 ║
║ docker logs autonet-frontend # 프론트엔드 로그 ║ docker logs autonet-backend --tail 50 # 백엔드 로그 ║
║ docker logs autonet-backend # 백엔드 로그 ║ docker compose -f docker-compose.production.yml up -d # 시작
║ docker restart autonet-frontend # 프론트엔드 재시작 ║ docker compose -f docker-compose.production.yml down # 중지
║ docker restart autonet-backend # 백엔드 재시작 ║ docker compose -f docker-compose.production.yml build --no-cache # 재빌드
╚═══════════════════════════════════════════════════════════════════════════╝ ═══════════════════════════════════════════════════════════════════════════════╝
╔═══════════════════════════════════════════════════════════════════════════╗ ═══════════════════════════════════════════════════════════════════════════════╗
배포 스크립트 PostgreSQL (서버1)
╠═══════════════════════════════════════════════════════════════════════════╣ ═══════════════════════════════════════════════════════════════════════════════╣
./deploy.sh promote # 스테이징 → 운영 승격 docker exec -it postgres-primary psql -U admin -d autonet # DB 접속
║ ./deploy.sh rollback # 직전 버전 롤백 ║ \dt # 테이블 목록
║ ./deploy.sh rollback-to <ts> # 특정 버전 롤백 \q # 종료
║ ./deploy.sh status # 현재 상태 확인 ║ ╚═══════════════════════════════════════════════════════════════════════════════╝
╚═══════════════════════════════════════════════════════════════════════════╝
``` ```
--- ---
## 9. 체크리스트 ## 11. 체크리스트
``` ### 최초 서버 설정
┌─────────────────────────────────────────────────────────────────────────────┐
│ 배포 전 체크리스트 │
└─────────────────────────────────────────────────────────────────────────────┘
배포 전: - [ ] 서버1: PostgreSQL 컨테이너 실행 중
┌─────────────────────────────────────────────────────────────────────────┐ - [ ] 서버1: autonet 데이터베이스 생성
│ [ ] 로컬에서 테스트 완료 │ - [ ] 서버1: 외부 접속 허용 (pg_hba.conf)
│ [ ] git status로 변경 파일 확인 │ - [ ] 서버1: Nginx Proxy Manager SSL 설정
│ [ ] 불필요한 파일 제외 (.env, node_modules 등) │ - [ ] 서버1: Custom Location (/api, /uploads) 설정
│ [ ] 커밋 메시지 작성 │ - [ ] 서버2: Docker Compose v2 설치
└─────────────────────────────────────────────────────────────────────────┘ - [ ] 서버2: backend/.env PostgreSQL 설정
- [ ] 서버2: requirements.txt에 psycopg2-binary 추가
- [ ] 서버2: config.py URL 인코딩 적용
스테이징 테스트: ### 배포 전
┌─────────────────────────────────────────────────────────────────────────┐
│ [ ] Frontend 정상 작동 (http://192.168.0.202:3001) │
│ [ ] Backend API 정상 작동 (http://192.168.0.202:8001/docs) │
│ [ ] 주요 기능 테스트 완료 │
│ [ ] 에러 로그 확인 (docker logs) │
└─────────────────────────────────────────────────────────────────────────┘
운영 배포 후: - [ ] 로컬 테스트 완료
┌─────────────────────────────────────────────────────────────────────────┐ - [ ] git status로 변경 파일 확인
│ [ ] 헬스체크 통과 │ - [ ] 커밋 메시지 작성
│ [ ] 주요 페이지 접속 확인 │
│ [ ] API 응답 확인 │
│ [ ] 이전 버전 백업 확인 (/opt/autonet/releases/) │
└─────────────────────────────────────────────────────────────────────────┘
```
--- ### 배포 후
## 10. 문제 해결 - [ ] docker ps로 컨테이너 상태 확인
- [ ] docker logs로 에러 확인
| 문제 | 해결 방법 | - [ ] https://autonetsellcar.com 접속 테스트
|------|----------| - [ ] API 응답 확인 (/api/docs)
| 컨테이너 시작 안됨 | `docker logs <container>` 로그 확인 |
| 포트 충돌 | `netstat -tlnp \| grep <port>` 확인 후 프로세스 종료 |
| 이미지 빌드 실패 | `docker build --no-cache` 캐시 없이 재빌드 |
| DB 연결 오류 | Volume 마운트 확인, 파일 권한 확인 |
| API 404 오류 | Backend 컨테이너 재시작, 라우터 등록 확인 |
--- ---

View File

@@ -2,16 +2,37 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# Install system dependencies # Install system dependencies for PostgreSQL and Playwright
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
gcc \ gcc \
libpq-dev \ 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/* && rm -rf /var/lib/apt/lists/*
# Install Python dependencies # Install Python dependencies
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r 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 application code
COPY . . COPY . .

View File

@@ -40,7 +40,7 @@ def get_localized_field(obj, field: str, lang: str) -> Optional[str]:
@router.get("/", response_model=List[HeroBannerLocalizedResponse]) @router.get("/", response_model=List[HeroBannerLocalizedResponse])
def get_hero_banners( 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) db: Session = Depends(get_db)
): ):
"""활성 히어로 배너 목록 조회 (Public)""" """활성 히어로 배너 목록 조회 (Public)"""

View File

@@ -64,7 +64,10 @@ class Settings(BaseSettings):
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
db_path = os.path.join(base_dir, "autonet.db") db_path = os.path.join(base_dir, "autonet.db")
return f"sqlite:///{db_path}" 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 @property
def REDIS_URL(self) -> str: def REDIS_URL(self) -> str:

View File

@@ -1,6 +1,8 @@
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import RedirectResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import os import os
import asyncio import asyncio
@@ -15,6 +17,42 @@ from datetime import datetime, timedelta
app_settings = get_settings() 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 # Create tables
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -118,6 +156,9 @@ app = FastAPI(
lifespan=lifespan lifespan=lifespan
) )
# Trailing slash middleware (must be added before CORS)
app.add_middleware(TrailingSlashMiddleware)
# CORS - credentials=True requires explicit origins (not "*") # CORS - credentials=True requires explicit origins (not "*")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -126,6 +167,8 @@ app.add_middleware(
"http://127.0.0.1:3000", "http://127.0.0.1:3000",
"http://localhost:8000", "http://localhost:8000",
"http://192.168.0.202:3000", # Local network "http://192.168.0.202:3000", # Local network
"https://autonetsellcar.com", # Production
"http://autonetsellcar.com", # Production (HTTP redirect)
], ],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],

View File

@@ -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()

View File

@@ -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()

View File

@@ -1,7 +1,7 @@
fastapi fastapi
uvicorn[standard] uvicorn[standard]
sqlalchemy sqlalchemy
# psycopg2-binary # Uncomment for PostgreSQL production psycopg2-binary # PostgreSQL production
redis redis
python-dotenv python-dotenv
pydantic pydantic
@@ -15,6 +15,8 @@ lxml
alembic alembic
email-validator email-validator
playwright # PDF capture for performance check reports playwright # PDF capture for performance check reports
img2pdf # Convert screenshots to PDF
pillow # Image processing for PDF generation
apscheduler # Scheduled tasks (exchange rate updates) apscheduler # Scheduled tasks (exchange rate updates)
stripe # Payment processing stripe # Payment processing
user-agents # Visitor tracking user-agents # Visitor tracking

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import Image from 'next/image'; import Image from 'next/image';
import api, { heroBannersApi, carmodooApi, vehicleRequestsApi } from '@/lib/api'; import api, { heroBannersApi, carmodooApi, vehicleRequestsApi } from '@/lib/api';
import { translateCarName } from '@/lib/i18n';
interface CarmodooMaker { interface CarmodooMaker {
code: string; code: string;
@@ -130,6 +131,8 @@ export default function CarsAdminPage() {
const [selectedCar, setSelectedCar] = useState<LocalCar | null>(null); const [selectedCar, setSelectedCar] = useState<LocalCar | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false); const [showDetailModal, setShowDetailModal] = useState(false);
const [currentImageIndex, setCurrentImageIndex] = useState(0); const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [selectedLocalCars, setSelectedLocalCars] = useState<Set<number>>(new Set());
const [registeringLocalBanner, setRegisteringLocalBanner] = useState(false);
// All Cars (public view) state // All Cars (public view) state
const [allCars, setAllCars] = useState<LocalCar[]>([]); const [allCars, setAllCars] = useState<LocalCar[]>([]);
@@ -549,30 +552,6 @@ export default function CarsAdminPage() {
} }
}; };
// 차량명 번역 함수
const translateCarName = (koreanName: string | undefined): string => {
if (!koreanName) return '-';
const translations: Record<string, string> = {
'현대': '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) => { const handleEditDealerDescription = async (car: CarmodooCarItem) => {
setEditingCar(car); setEditingCar(car);
@@ -700,11 +679,13 @@ export default function CarsAdminPage() {
const bannerData = { const bannerData = {
title_ko: car.car_name || '', title_ko: car.car_name || '',
title_en: translateCarName(car.car_name), title_en: translateCarName(car.car_name, 'en'),
title_mn: translateCarName(car.car_name), title_mn: translateCarName(car.car_name, 'mn'),
title_ru: translateCarName(car.car_name, 'ru'),
subtitle_ko: `${car.year}년식 | ${car.mileage?.toLocaleString()}km`, subtitle_ko: `${car.year}년식 | ${car.mileage?.toLocaleString()}km`,
subtitle_en: `${car.year} | ${car.mileage?.toLocaleString()}km`, subtitle_en: `${car.year} | ${car.mileage?.toLocaleString()}km`,
subtitle_mn: `${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, image_url: localImageUrl,
link_url: `/cars/${carId}`, link_url: `/cars/${carId}`,
is_active: true, 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용) // 차량 추천 목록에 추가 함수 (Vehicle Request용)
const handleAddToRequest = async () => { const handleAddToRequest = async () => {
if (!requestId) return; if (!requestId) return;
@@ -1029,7 +1084,34 @@ export default function CarsAdminPage() {
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-gray-800"> <h2 className="text-lg font-semibold text-gray-800">
Imported Cars ({localTotal} total) Imported Cars ({localTotal} total)
{selectedLocalCars.size > 0 && (
<span className="ml-2 text-sm font-normal text-purple-600">
({selectedLocalCars.size} selected)
</span>
)}
</h2> </h2>
<div className="flex items-center gap-3">
{selectedLocalCars.size > 0 && (
<button
onClick={handleRegisterLocalCarAsBanner}
disabled={registeringLocalBanner}
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{registeringLocalBanner ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Registering...</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>Register as Banner</span>
</>
)}
</button>
)}
<button <button
onClick={() => loadLocalCars(localPage)} onClick={() => loadLocalCars(localPage)}
disabled={localLoading} disabled={localLoading}
@@ -1041,6 +1123,7 @@ export default function CarsAdminPage() {
Refresh Refresh
</button> </button>
</div> </div>
</div>
{localLoading ? ( {localLoading ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
@@ -1066,6 +1149,14 @@ export default function CarsAdminPage() {
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-gray-200"> <tr className="border-b border-gray-200">
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">
<input
type="checkbox"
checked={selectedLocalCars.size === localCars.length && localCars.length > 0}
onChange={handleSelectAllLocalCars}
className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500"
/>
</th>
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">Display</th> <th className="py-3 px-2 text-center text-sm font-medium text-gray-600">Display</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Image</th> <th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Image</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Car Name</th> <th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Car Name</th>
@@ -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' : ''}`} className={`border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${!car.is_displayed ? 'opacity-60' : ''}`}
onClick={() => handleCarClick(car)} onClick={() => handleCarClick(car)}
> >
<td className="py-3 px-2 text-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedLocalCars.has(car.id)}
onChange={() => {
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"
/>
</td>
<td className="py-3 px-2 text-center"> <td className="py-3 px-2 text-center">
<button <button
onClick={(e) => handleToggleDisplay(car, e)} onClick={(e) => handleToggleDisplay(car, e)}
@@ -2276,7 +2385,7 @@ export default function CarsAdminPage() {
<textarea <textarea
value={editCommentData.ko} value={editCommentData.ko}
onChange={(e) => setEditCommentData({ ...editCommentData, ko: e.target.value })} onChange={(e) => setEditCommentData({ ...editCommentData, ko: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
rows={3} rows={3}
/> />
</div> </div>
@@ -2285,7 +2394,7 @@ export default function CarsAdminPage() {
<textarea <textarea
value={editCommentData.en} value={editCommentData.en}
onChange={(e) => setEditCommentData({ ...editCommentData, en: e.target.value })} onChange={(e) => setEditCommentData({ ...editCommentData, en: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
rows={3} rows={3}
/> />
</div> </div>
@@ -2294,7 +2403,7 @@ export default function CarsAdminPage() {
<textarea <textarea
value={editCommentData.mn} value={editCommentData.mn}
onChange={(e) => setEditCommentData({ ...editCommentData, mn: e.target.value })} onChange={(e) => setEditCommentData({ ...editCommentData, mn: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
rows={3} rows={3}
/> />
</div> </div>
@@ -2303,7 +2412,7 @@ export default function CarsAdminPage() {
<textarea <textarea
value={editCommentData.ru} value={editCommentData.ru}
onChange={(e) => setEditCommentData({ ...editCommentData, ru: e.target.value })} onChange={(e) => setEditCommentData({ ...editCommentData, ru: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
rows={3} rows={3}
/> />
</div> </div>

View File

@@ -133,7 +133,7 @@ export const inquiriesApi = {
// Hero Banners API // Hero Banners API
export const heroBannersApi = { export const heroBannersApi = {
// Public APIs // Public APIs
getList: async (lang: string = 'ko'): Promise<HeroBanner[]> => { getList: async (lang: string = 'en'): Promise<HeroBanner[]> => {
const { data } = await api.get('/hero-banners/', { params: { lang } }); const { data } = await api.get('/hero-banners/', { params: { lang } });
return data; return data;
}, },