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:
@@ -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,38 +329,63 @@
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
코드 수정 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/)
|
||||
@@ -62,11 +398,17 @@
|
||||
├── staging/ # 스테이징 환경
|
||||
│ ├── frontend/
|
||||
│ ├── backend/
|
||||
│ │ └── .env # 스테이징 환경변수
|
||||
│ └── docker-compose.staging.yml
|
||||
│
|
||||
├── production/ # 운영 환경
|
||||
│ ├── frontend/
|
||||
│ ├── backend/
|
||||
│ │ ├── .env # 운영 환경변수 (PostgreSQL)
|
||||
│ │ ├── requirements.txt # psycopg2-binary 포함
|
||||
│ │ └── app/
|
||||
│ │ └── config.py # URL 인코딩 적용
|
||||
│ ├── carmodoo-agent/
|
||||
│ └── docker-compose.production.yml
|
||||
│
|
||||
├── releases/ # 롤백용 백업
|
||||
@@ -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 컨테이너 구성도 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
운영 환경 (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) ║
|
||||
╠═══════════════════════════════════════════════════════════════════════════╣
|
||||
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||
║ 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 # 백엔드 재시작 ║
|
||||
╚═══════════════════════════════════════════════════════════════════════════╝
|
||||
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||
║ 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 # 재빌드 ║
|
||||
╚═══════════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
╔═══════════════════════════════════════════════════════════════════════════╗
|
||||
║ 배포 스크립트 ║
|
||||
╠═══════════════════════════════════════════════════════════════════════════╣
|
||||
║ ./deploy.sh promote # 스테이징 → 운영 승격 ║
|
||||
║ ./deploy.sh rollback # 직전 버전 롤백 ║
|
||||
║ ./deploy.sh rollback-to <ts> # 특정 버전 롤백 ║
|
||||
║ ./deploy.sh status # 현재 상태 확인 ║
|
||||
╚═══════════════════════════════════════════════════════════════════════════╝
|
||||
╔═══════════════════════════════════════════════════════════════════════════════╗
|
||||
║ PostgreSQL (서버1) ║
|
||||
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||
║ docker exec -it postgres-primary psql -U admin -d autonet # DB 접속 ║
|
||||
║ \dt # 테이블 목록 ║
|
||||
║ \q # 종료 ║
|
||||
╚═══════════════════════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 체크리스트
|
||||
## 11. 체크리스트
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 배포 전 체크리스트 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
### 최초 서버 설정
|
||||
|
||||
배포 전:
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ [ ] 로컬에서 테스트 완료 │
|
||||
│ [ ] git status로 변경 파일 확인 │
|
||||
│ [ ] 불필요한 파일 제외 (.env, node_modules 등) │
|
||||
│ [ ] 커밋 메시지 작성 │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
- [ ] 서버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 인코딩 적용
|
||||
|
||||
스테이징 테스트:
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ [ ] Frontend 정상 작동 (http://192.168.0.202:3001) │
|
||||
│ [ ] Backend API 정상 작동 (http://192.168.0.202:8001/docs) │
|
||||
│ [ ] 주요 기능 테스트 완료 │
|
||||
│ [ ] 에러 로그 확인 (docker logs) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
### 배포 전
|
||||
|
||||
운영 배포 후:
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ [ ] 헬스체크 통과 │
|
||||
│ [ ] 주요 페이지 접속 확인 │
|
||||
│ [ ] API 응답 확인 │
|
||||
│ [ ] 이전 버전 백업 확인 (/opt/autonet/releases/) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
- [ ] 로컬 테스트 완료
|
||||
- [ ] git status로 변경 파일 확인
|
||||
- [ ] 커밋 메시지 작성
|
||||
|
||||
---
|
||||
### 배포 후
|
||||
|
||||
## 10. 문제 해결
|
||||
|
||||
| 문제 | 해결 방법 |
|
||||
|------|----------|
|
||||
| 컨테이너 시작 안됨 | `docker logs <container>` 로그 확인 |
|
||||
| 포트 충돌 | `netstat -tlnp \| grep <port>` 확인 후 프로세스 종료 |
|
||||
| 이미지 빌드 실패 | `docker build --no-cache` 캐시 없이 재빌드 |
|
||||
| DB 연결 오류 | Volume 마운트 확인, 파일 권한 확인 |
|
||||
| API 404 오류 | Backend 컨테이너 재시작, 라우터 등록 확인 |
|
||||
- [ ] docker ps로 컨테이너 상태 확인
|
||||
- [ ] docker logs로 에러 확인
|
||||
- [ ] https://autonetsellcar.com 접속 테스트
|
||||
- [ ] API 응답 확인 (/api/docs)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 . .
|
||||
|
||||
|
||||
@@ -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)"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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=["*"],
|
||||
|
||||
124
backend/fix_banner_translations.py
Normal file
124
backend/fix_banner_translations.py
Normal 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()
|
||||
297
backend/migrate_to_postgres.py
Normal file
297
backend/migrate_to_postgres.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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<LocalCar | null>(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||
const [selectedLocalCars, setSelectedLocalCars] = useState<Set<number>>(new Set());
|
||||
const [registeringLocalBanner, setRegisteringLocalBanner] = useState(false);
|
||||
|
||||
// All Cars (public view) state
|
||||
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) => {
|
||||
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,7 +1084,34 @@ export default function CarsAdminPage() {
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
Imported Cars ({localTotal} total)
|
||||
{selectedLocalCars.size > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-purple-600">
|
||||
({selectedLocalCars.size} selected)
|
||||
</span>
|
||||
)}
|
||||
</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
|
||||
onClick={() => loadLocalCars(localPage)}
|
||||
disabled={localLoading}
|
||||
@@ -1041,6 +1123,7 @@ export default function CarsAdminPage() {
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
@@ -1066,6 +1149,14 @@ export default function CarsAdminPage() {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<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-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>
|
||||
@@ -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)}
|
||||
>
|
||||
<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">
|
||||
<button
|
||||
onClick={(e) => handleToggleDisplay(car, e)}
|
||||
@@ -2276,7 +2385,7 @@ export default function CarsAdminPage() {
|
||||
<textarea
|
||||
value={editCommentData.ko}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
@@ -2285,7 +2394,7 @@ export default function CarsAdminPage() {
|
||||
<textarea
|
||||
value={editCommentData.en}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
@@ -2294,7 +2403,7 @@ export default function CarsAdminPage() {
|
||||
<textarea
|
||||
value={editCommentData.mn}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
@@ -2303,7 +2412,7 @@ export default function CarsAdminPage() {
|
||||
<textarea
|
||||
value={editCommentData.ru}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,7 @@ export const inquiriesApi = {
|
||||
// Hero Banners API
|
||||
export const heroBannersApi = {
|
||||
// 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 } });
|
||||
return data;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user