Add SOLD OUT badge and improve deployment docs
- Add SOLD OUT overlay on car detail page image - Add SOLD OUT badge next to car name - Add Sold Out status in Admin Cars detail view - Add soldout field to Car TypeScript interface - Create PRODUCTION_VALUES.md for deployment reference - Update CLAUDE.md with CRITICAL deployment section - Update TROUBLESHOOTING.md with recurring errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
53
CLAUDE.md
53
CLAUDE.md
@@ -8,6 +8,59 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# ⛔ CRITICAL: 배포 전 필수 확인사항 (DO NOT SKIP!)
|
||||||
|
|
||||||
|
## 🚨 Production 환경 고정값 (절대 변경 금지!)
|
||||||
|
|
||||||
|
| 항목 | 올바른 값 | 잘못된 예 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| **DB_NAME** | `autonet` | ~~mongolcar~~, ~~autonet_db~~ |
|
||||||
|
| **DB_PASSWORD** | `roskfl@1122` (@ 포함, URL 인코딩 필요) | - |
|
||||||
|
| **API_URL (Frontend)** | `https://autonetsellcar.com` | ~~http://192.168.0.202:8000~~ |
|
||||||
|
| **Uploads 경로** | `/opt/autonet/production/backend/uploads` | ~~`/home/damon/mongolcar/data/uploads`~~ |
|
||||||
|
| **배포 디렉토리** | `/opt/autonet/production` | ~~`/home/damon/mongolcar`~~ |
|
||||||
|
|
||||||
|
## 🚫 절대 하지 말 것
|
||||||
|
|
||||||
|
1. **수동 `docker run`에서 환경변수 직접 타이핑 금지** - [PRODUCTION_VALUES.md](./PRODUCTION_VALUES.md)에서 복사만 허용
|
||||||
|
2. **DB_NAME 직접 타이핑 금지** - 반드시 `autonet` 복사/붙여넣기
|
||||||
|
3. **`.env` 파일 덮어쓰기 금지** - 서버의 기존 `.env` 보존
|
||||||
|
4. **`/home/damon/mongolcar/data/uploads` 사용 금지** - 비어있는 잘못된 경로
|
||||||
|
|
||||||
|
## ✅ 배포 전 필수 검증 명령어
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. DB 이름 확인 (반드시 autonet이어야 함!)
|
||||||
|
ssh server1 "docker exec postgres-primary psql -U admin -d autonet -c 'SELECT COUNT(*) FROM cars;'"
|
||||||
|
|
||||||
|
# 2. Uploads 경로에 파일 있는지 확인
|
||||||
|
ssh server2 "ls /opt/autonet/production/backend/uploads/cars/ | head -5"
|
||||||
|
|
||||||
|
# 3. 배포 후 API 확인
|
||||||
|
curl -s https://autonetsellcar.com/api/hero-banners/ | head -c 100
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 표준 컨테이너 실행 명령어 (복사해서 사용!)
|
||||||
|
|
||||||
|
**⚠️ 아래 명령어를 그대로 복사해서 사용하세요. 절대 직접 타이핑하지 마세요!**
|
||||||
|
|
||||||
|
### Backend 실행
|
||||||
|
```bash
|
||||||
|
ssh server2 "docker stop autonet-backend 2>/dev/null; docker rm autonet-backend 2>/dev/null; docker run -d --name autonet-backend --restart unless-stopped -p 8000:8000 -e USE_SQLITE=False -e DB_HOST=192.168.0.201 -e DB_PORT=5432 -e DB_NAME=autonet -e DB_USER=admin -e 'DB_PASSWORD=roskfl@1122' -e REDIS_HOST=192.168.0.201 -e REDIS_PORT=6379 -e 'REDIS_PASSWORD=roskfl@1122' -e SECRET_KEY=YourSuperSecretKeyForJWT123! -e AGENT_API_KEY=AgentApiKey123! -v /opt/autonet/production/backend/uploads:/app/uploads --network mongolcar-network production-backend"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend 빌드 전 (.env.production 설정)
|
||||||
|
```bash
|
||||||
|
ssh server2 "echo 'NEXT_PUBLIC_API_URL=https://autonetsellcar.com' > /home/damon/mongolcar/frontend/.env.production"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend 실행
|
||||||
|
```bash
|
||||||
|
ssh server2 "docker stop autonet-frontend 2>/dev/null; docker rm autonet-frontend 2>/dev/null; docker run -d --name autonet-frontend --restart unless-stopped -p 3000:3000 --network mongolcar-network production-frontend"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. 프로젝트 구조
|
## 1. 프로젝트 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
이 문서는 시스템 아키텍처와 코드 수정부터 운영 배포까지의 전체 과정을 설명합니다.
|
이 문서는 시스템 아키텍처와 코드 수정부터 운영 배포까지의 전체 과정을 설명합니다.
|
||||||
|
|
||||||
|
> **중요**: 배포 전 반드시 [PRODUCTION_VALUES.md](./PRODUCTION_VALUES.md)의 값을 확인하세요!
|
||||||
|
> 반복되는 오류 해결은 [TROUBLESHOOTING.md](./TROUBLESHOOTING.md)를 참고하세요.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 시스템 아키텍처 개요
|
## 1. 시스템 아키텍처 개요
|
||||||
@@ -150,7 +153,7 @@ docker exec postgres-primary env | grep POSTGRES
|
|||||||
# 출력:
|
# 출력:
|
||||||
# POSTGRES_USER=admin
|
# POSTGRES_USER=admin
|
||||||
# POSTGRES_PASSWORD=roskfl@1122
|
# POSTGRES_PASSWORD=roskfl@1122
|
||||||
# POSTGRES_DB=mongolcar
|
# POSTGRES_DB=mongolcar (컨테이너 기본 DB, AutonetSellCar는 autonet DB 사용!)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 데이터베이스 생성
|
### 4.2 데이터베이스 생성
|
||||||
|
|||||||
179
PRODUCTION_VALUES.md
Normal file
179
PRODUCTION_VALUES.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# AutonetSellCar Production Values (Single Source of Truth)
|
||||||
|
|
||||||
|
**이 파일의 값들은 절대 변경하지 마세요!**
|
||||||
|
**DO NOT MODIFY THESE VALUES!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server Information
|
||||||
|
|
||||||
|
| 서버 | IP | 역할 |
|
||||||
|
|------|-----|------|
|
||||||
|
| Server1 | 192.168.0.201 | PostgreSQL Primary |
|
||||||
|
| Server2 | 192.168.0.202 | Production (Frontend + Backend) |
|
||||||
|
| Server3 | 192.168.0.203 | Grantech.kr |
|
||||||
|
| Server4 | 192.168.0.204 | Development |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Configuration
|
||||||
|
|
||||||
|
| 항목 | 값 | 비고 |
|
||||||
|
|------|-----|------|
|
||||||
|
| **DB_NAME** | `autonet` | ~~mongolcar~~ 절대 아님! |
|
||||||
|
| **DB_USER** | `admin` | |
|
||||||
|
| **DB_PASSWORD** | `roskfl@1122` | `@` 포함, URL 인코딩 필요 |
|
||||||
|
| **DB_HOST** | `192.168.0.201` | Server1 |
|
||||||
|
| **DB_PORT** | `5432` | PostgreSQL 기본 포트 |
|
||||||
|
|
||||||
|
### Database URL Format
|
||||||
|
|
||||||
|
```
|
||||||
|
postgresql://admin:roskfl%401122@192.168.0.201:5432/autonet
|
||||||
|
^^^^^^^^
|
||||||
|
@가 %40으로 인코딩됨
|
||||||
|
```
|
||||||
|
|
||||||
|
### config.py에서 quote_plus() 필수
|
||||||
|
|
||||||
|
```python
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
encoded_password = quote_plus(self.DB_PASSWORD) # roskfl@1122 → roskfl%401122
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Container Configuration
|
||||||
|
|
||||||
|
### Backend Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name autonet-backend \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 8000:8000 \
|
||||||
|
-e USE_SQLITE=False \
|
||||||
|
-e DB_HOST=192.168.0.201 \
|
||||||
|
-e DB_PORT=5432 \
|
||||||
|
-e DB_NAME=autonet \
|
||||||
|
-e DB_USER=admin \
|
||||||
|
-e "DB_PASSWORD=roskfl@1122" \
|
||||||
|
-v /opt/autonet/production/backend/uploads:/app/uploads \
|
||||||
|
production-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
**주의사항:**
|
||||||
|
- `DB_NAME=autonet` (절대 mongolcar 아님!)
|
||||||
|
- `-v /opt/autonet/production/backend/uploads:/app/uploads` (절대 `/home/damon/mongolcar/...` 아님!)
|
||||||
|
- `DB_PASSWORD`에 `@` 포함됨 - 따옴표로 감싸기
|
||||||
|
|
||||||
|
### Frontend Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 빌드 전 .env.production 확인!
|
||||||
|
echo 'NEXT_PUBLIC_API_URL=https://autonetsellcar.com' > /opt/autonet/production/frontend/.env.production
|
||||||
|
|
||||||
|
# 빌드 (반드시 --no-cache)
|
||||||
|
docker build --no-cache -t production-frontend .
|
||||||
|
|
||||||
|
# 실행
|
||||||
|
docker run -d \
|
||||||
|
--name autonet-frontend \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 3000:3000 \
|
||||||
|
production-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
**주의사항:**
|
||||||
|
- `NEXT_PUBLIC_API_URL=https://autonetsellcar.com` (절대 `http://192.168.0.202:8000` 아님!)
|
||||||
|
- HTTPS 필수 (Mixed Content 방지)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Paths
|
||||||
|
|
||||||
|
| 용도 | 올바른 경로 | 잘못된 경로 |
|
||||||
|
|------|-------------|-------------|
|
||||||
|
| **Uploads** | `/opt/autonet/production/backend/uploads` | `/home/damon/mongolcar/data/uploads` |
|
||||||
|
| **Backend 소스** | `/opt/autonet/production/backend` | - |
|
||||||
|
| **Frontend 소스** | `/opt/autonet/production/frontend` | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Backend (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
USE_SQLITE=False
|
||||||
|
DB_HOST=192.168.0.201
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=autonet
|
||||||
|
DB_USER=admin
|
||||||
|
DB_PASSWORD=roskfl@1122
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (.env.production)
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_API_URL=https://autonetsellcar.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
|
||||||
|
### 1. DB 연결 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh server2 "docker exec autonet-backend python -c \"from app.config import get_settings; print('DB_NAME:', get_settings().DB_NAME)\""
|
||||||
|
# 예상 출력: DB_NAME: autonet
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. API 데이터 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://autonetsellcar.com/api/hero-banners/ | jq 'length'
|
||||||
|
# 예상 출력: 8 이상 (0이면 문제!)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 이미지 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://autonetsellcar.com/uploads/cars/1/image_0.jpg
|
||||||
|
# 예상 출력: HTTP/2 200
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Frontend API URL 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh server2 "docker exec autonet-frontend grep -o 'autonetsellcar.com' /app/.next/static/chunks/app/**/*.js | head -1"
|
||||||
|
# 예상 출력: autonetsellcar.com (localhost나 192.168.0.202가 아님!)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference Card
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ PRODUCTION VALUES │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ DB_NAME: autonet (NOT mongolcar!) │
|
||||||
|
│ DB_PASSWORD: roskfl@1122 (@ needs URL encoding) │
|
||||||
|
│ API_URL: https://autonetsellcar.com (NOT http://...) │
|
||||||
|
│ UPLOADS: /opt/autonet/production/backend/uploads │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Change History
|
||||||
|
|
||||||
|
| 날짜 | 변경 내용 |
|
||||||
|
|------|----------|
|
||||||
|
| 2026-01-02 | 문서 최초 생성 (반복 배포 오류 방지 목적) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**이 문서의 값과 다르면 무조건 틀린 것입니다!**
|
||||||
@@ -233,9 +233,162 @@ docker exec autonet-frontend sh -c "grep -r 'localhost:8000' /app/.next/static/c
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-02: 반복되는 배포 오류 종합 (CRITICAL)
|
||||||
|
|
||||||
|
### 문제 1: DB_NAME 오류 (`mongolcar` vs `autonet`)
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
- 사이트에 차량/배너 없음
|
||||||
|
- API가 빈 배열 `[]` 반환
|
||||||
|
- 로그인은 되지만 데이터 없음
|
||||||
|
|
||||||
|
**원인**:
|
||||||
|
- 수동 `docker run` 시 DB_NAME을 `mongolcar`로 잘못 입력
|
||||||
|
- PostgreSQL에 `mongolcar`와 `autonet` 두 DB가 존재
|
||||||
|
- `mongolcar`는 비어있고, 실제 데이터는 `autonet`에 있음
|
||||||
|
|
||||||
|
**확인 방법**:
|
||||||
|
```bash
|
||||||
|
# 현재 연결된 DB 확인
|
||||||
|
ssh server2 "docker exec autonet-backend python -c \"from app.config import get_settings; print('DB_NAME:', get_settings().DB_NAME)\""
|
||||||
|
|
||||||
|
# DB별 데이터 확인
|
||||||
|
ssh server1 "docker exec postgres-primary psql -U admin -d autonet -c 'SELECT COUNT(*) FROM cars;'"
|
||||||
|
ssh server1 "docker exec postgres-primary psql -U admin -d mongolcar -c 'SELECT COUNT(*) FROM cars;'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
- `DB_NAME=autonet` 확인 후 컨테이너 재시작
|
||||||
|
- CLAUDE.md의 표준 명령어 복사해서 사용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 문제 2: 비밀번호 `@` 파싱 오류
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
- `could not translate host name "1122@192.168.0.201"`
|
||||||
|
- 백엔드 컨테이너 계속 재시작
|
||||||
|
|
||||||
|
**원인**:
|
||||||
|
- `DB_PASSWORD=roskfl@1122`의 `@`가 URL에서 호스트 구분자로 인식됨
|
||||||
|
- `config.py`에 `quote_plus()` URL 인코딩이 없거나, Docker 이미지가 오래됨
|
||||||
|
|
||||||
|
**확인 방법**:
|
||||||
|
```bash
|
||||||
|
# config.py에 quote_plus 있는지 확인
|
||||||
|
ssh server2 "docker exec autonet-backend cat /app/app/config.py | grep quote_plus"
|
||||||
|
```
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
1. `config.py`에 `quote_plus()` 적용 확인
|
||||||
|
2. Docker 이미지 재빌드: `docker build --no-cache -t production-backend ./backend`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 문제 3: 이미지 404 (잘못된 uploads 경로)
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
- `/uploads/cars/40/image_0.jpg` → 404
|
||||||
|
- Hero 배너에 기본 이미지만 표시
|
||||||
|
|
||||||
|
**원인**:
|
||||||
|
- Production 컨테이너가 잘못된 경로 마운트: `/home/damon/mongolcar/data/uploads` (비어있음!)
|
||||||
|
- 실제 이미지는 `/opt/autonet/production/backend/uploads`에 있음
|
||||||
|
|
||||||
|
**확인 방법**:
|
||||||
|
```bash
|
||||||
|
# 올바른 경로 (파일 있어야 함)
|
||||||
|
ssh server2 "ls /opt/autonet/production/backend/uploads/cars/ | head -5"
|
||||||
|
|
||||||
|
# 잘못된 경로 (비어있음!)
|
||||||
|
ssh server2 "ls /home/damon/mongolcar/data/uploads/cars/ 2>/dev/null || echo 'Empty or not exists'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
- 볼륨 마운트를 `-v /opt/autonet/production/backend/uploads:/app/uploads`로 변경
|
||||||
|
- CLAUDE.md의 표준 명령어 사용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 문제 4: Mixed Content (HTTPS/HTTP)
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
- 브라우저 콘솔에 Mixed Content 오류
|
||||||
|
- API 요청 실패
|
||||||
|
- 사이트에 기본 이미지만 표시
|
||||||
|
|
||||||
|
**원인**:
|
||||||
|
- Frontend가 `NEXT_PUBLIC_API_URL=http://192.168.0.202:8000`으로 빌드됨
|
||||||
|
- HTTPS 페이지에서 HTTP API 호출 시 브라우저가 차단
|
||||||
|
|
||||||
|
**확인 방법**:
|
||||||
|
```bash
|
||||||
|
# 빌드된 JS에서 API URL 확인
|
||||||
|
ssh server2 "docker exec autonet-frontend grep -r '192.168.0.202:8000' /app/.next/static/chunks/ | head -1"
|
||||||
|
```
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```bash
|
||||||
|
# Frontend 빌드 전 필수!
|
||||||
|
ssh server2 "echo 'NEXT_PUBLIC_API_URL=https://autonetsellcar.com' > /home/damon/mongolcar/frontend/.env.production"
|
||||||
|
ssh server2 "cd /home/damon/mongolcar/frontend && docker build --no-cache -t production-frontend ."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 문제 5: .env 파일이 디렉토리로 생성됨
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
- 로그인 실패
|
||||||
|
- `(sqlite3.OperationalError) no such column` 에러
|
||||||
|
- 환경변수 로드 실패
|
||||||
|
|
||||||
|
**원인**:
|
||||||
|
- docker-compose 볼륨 마운트 시 `.env` 파일이 없으면 디렉토리로 생성됨
|
||||||
|
|
||||||
|
**확인 방법**:
|
||||||
|
```bash
|
||||||
|
ssh server2 "ls -la /opt/autonet/production/backend/.env"
|
||||||
|
# 'd'로 시작하면 디렉토리 (문제!)
|
||||||
|
```
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```bash
|
||||||
|
# 디렉토리면 삭제 후 파일로 재생성
|
||||||
|
ssh server2 "rm -rf /opt/autonet/production/backend/.env"
|
||||||
|
ssh server2 "cat > /opt/autonet/production/backend/.env << 'EOF'
|
||||||
|
USE_SQLITE=False
|
||||||
|
DB_HOST=192.168.0.201
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=autonet
|
||||||
|
DB_USER=admin
|
||||||
|
DB_PASSWORD=roskfl@1122
|
||||||
|
EOF"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 재발 방지 체크리스트
|
||||||
|
|
||||||
|
배포 시 반드시 확인:
|
||||||
|
|
||||||
|
- [ ] DB_NAME이 `autonet`인가? (절대 `mongolcar` 아님!)
|
||||||
|
- [ ] Uploads 경로가 `/opt/autonet/production/backend/uploads`인가?
|
||||||
|
- [ ] Frontend `.env.production`에 `https://autonetsellcar.com`이 설정되어 있는가?
|
||||||
|
- [ ] `--no-cache`로 이미지를 빌드했는가?
|
||||||
|
- [ ] 배포 후 `curl https://autonetsellcar.com/api/hero-banners/`로 데이터 확인했는가?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 변경 이력
|
## 변경 이력
|
||||||
|
|
||||||
| 날짜 | 문제 | 해결 |
|
| 날짜 | 문제 | 해결 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
| 2026-01-02 | DB_NAME 오류 (mongolcar vs autonet) | DB_NAME=autonet 확인, 표준 명령어 사용 |
|
||||||
|
| 2026-01-02 | 비밀번호 @ 파싱 오류 | quote_plus() URL 인코딩 적용 |
|
||||||
|
| 2026-01-02 | 이미지 404 (잘못된 uploads 경로) | /opt/autonet/production/backend/uploads 사용 |
|
||||||
|
| 2026-01-02 | Mixed Content 오류 | NEXT_PUBLIC_API_URL=https://autonetsellcar.com |
|
||||||
| 2024-12-30 | 운영서버 이미지 미표시 (배너) | 소스 코드 동기화 + Docker 재빌드 |
|
| 2024-12-30 | 운영서버 이미지 미표시 (배너) | 소스 코드 동기화 + Docker 재빌드 |
|
||||||
| 2024-12-30 | 차량 상세 이미지 미표시 | `getImageUrl()` 함수 수정 |
|
| 2024-12-30 | 차량 상세 이미지 미표시 | `getImageUrl()` 함수 수정 |
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import List
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import VehicleRequest, RequestVehicle, PurchasedVehicle, User, DealerInfo, SystemSettings
|
from ..models import VehicleRequest, RequestVehicle, PurchasedVehicle, User, DealerInfo, SystemSettings, Car
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
VehicleRequestCreate, VehicleRequestResponse,
|
VehicleRequestCreate, VehicleRequestResponse,
|
||||||
RequestVehicleCreate, RequestVehicleResponse, RequestVehicleApprove,
|
RequestVehicleCreate, RequestVehicleResponse, RequestVehicleApprove,
|
||||||
@@ -100,9 +100,21 @@ def get_my_requests(
|
|||||||
else:
|
else:
|
||||||
approved_vehicles = []
|
approved_vehicles = []
|
||||||
|
|
||||||
|
# Enrich approved vehicles with latest soldout status from cars table
|
||||||
|
enriched_vehicles = []
|
||||||
|
for v in approved_vehicles:
|
||||||
|
vehicle_response = RequestVehicleResponse.model_validate(v)
|
||||||
|
# Get latest soldout status from cars table
|
||||||
|
if v.car_id:
|
||||||
|
car = db.query(Car).filter(Car.id == v.car_id).first()
|
||||||
|
if car:
|
||||||
|
# Add soldout status to car_data
|
||||||
|
vehicle_response.car_data = {**vehicle_response.car_data, "soldout": car.soldout}
|
||||||
|
enriched_vehicles.append(vehicle_response)
|
||||||
|
|
||||||
result.append(VehicleRequestWithVehicles(
|
result.append(VehicleRequestWithVehicles(
|
||||||
request=VehicleRequestResponse.model_validate(req),
|
request=VehicleRequestResponse.model_validate(req),
|
||||||
approved_vehicles=[RequestVehicleResponse.model_validate(v) for v in approved_vehicles]
|
approved_vehicles=enriched_vehicles
|
||||||
))
|
))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -189,9 +201,30 @@ def admin_get_request_detail(
|
|||||||
if not request:
|
if not request:
|
||||||
raise HTTPException(status_code=404, detail="Request not found")
|
raise HTTPException(status_code=404, detail="Request not found")
|
||||||
|
|
||||||
|
# Import CarPerformanceCheck for PDF status
|
||||||
|
from ..models import CarPerformanceCheck
|
||||||
|
|
||||||
|
# Enrich with PDF status and soldout
|
||||||
|
enriched_vehicles = []
|
||||||
|
for v in request.recommended_vehicles:
|
||||||
|
vehicle_response = RequestVehicleResponse.model_validate(v)
|
||||||
|
|
||||||
|
# Get PDF status and soldout from car
|
||||||
|
if v.car_id:
|
||||||
|
car = db.query(Car).filter(Car.id == v.car_id).first()
|
||||||
|
perf_check = db.query(CarPerformanceCheck).filter(CarPerformanceCheck.car_id == v.car_id).first()
|
||||||
|
|
||||||
|
vehicle_response.car_data = {
|
||||||
|
**vehicle_response.car_data,
|
||||||
|
"soldout": car.soldout if car else False,
|
||||||
|
"has_pdf": bool(perf_check and perf_check.pdf_path),
|
||||||
|
"check_num": perf_check.check_number if perf_check else None,
|
||||||
|
}
|
||||||
|
enriched_vehicles.append(vehicle_response)
|
||||||
|
|
||||||
return VehicleRequestWithVehicles(
|
return VehicleRequestWithVehicles(
|
||||||
request=VehicleRequestResponse.model_validate(request),
|
request=VehicleRequestResponse.model_validate(request),
|
||||||
approved_vehicles=[RequestVehicleResponse.model_validate(v) for v in request.recommended_vehicles]
|
approved_vehicles=enriched_vehicles
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -202,7 +235,7 @@ def admin_add_vehicle(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Admin: Add a vehicle to a request"""
|
"""Admin: Add a vehicle to a request (also imports to cars table)"""
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin:
|
||||||
raise HTTPException(status_code=403, detail="Admin access required")
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
|
||||||
@@ -210,9 +243,51 @@ def admin_add_vehicle(
|
|||||||
if not request:
|
if not request:
|
||||||
raise HTTPException(status_code=404, detail="Request not found")
|
raise HTTPException(status_code=404, detail="Request not found")
|
||||||
|
|
||||||
|
# Extract car data
|
||||||
|
car_data = vehicle_data.car_data
|
||||||
|
source_id = str(car_data.get("id", ""))
|
||||||
|
|
||||||
|
# Check if car already exists in cars table
|
||||||
|
existing_car = None
|
||||||
|
if source_id:
|
||||||
|
existing_car = db.query(Car).filter(
|
||||||
|
Car.source == "carmodoo",
|
||||||
|
Car.source_id == source_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
car_id = None
|
||||||
|
if existing_car:
|
||||||
|
car_id = existing_car.id
|
||||||
|
elif source_id:
|
||||||
|
# Create new car record from car_data
|
||||||
|
new_car = Car(
|
||||||
|
source="carmodoo",
|
||||||
|
source_id=source_id,
|
||||||
|
car_name=car_data.get("car_name", ""),
|
||||||
|
year=car_data.get("year"),
|
||||||
|
mileage=car_data.get("mileage"),
|
||||||
|
price_krw=car_data.get("original_price"),
|
||||||
|
fuel=car_data.get("fuel"),
|
||||||
|
transmission=car_data.get("transmission"),
|
||||||
|
color=car_data.get("color"),
|
||||||
|
displacement=car_data.get("displacement"),
|
||||||
|
margin_krw=car_data.get("korea_margin"),
|
||||||
|
margin_mn=car_data.get("mongolia_margin"),
|
||||||
|
check_num=car_data.get("check_num"),
|
||||||
|
is_displayed=True, # Displayed so user can view recommended car
|
||||||
|
status="active"
|
||||||
|
)
|
||||||
|
db.add(new_car)
|
||||||
|
db.flush()
|
||||||
|
car_id = new_car.id
|
||||||
|
|
||||||
|
# Update car_data with local car_id for frontend
|
||||||
|
car_data["local_car_id"] = car_id
|
||||||
|
|
||||||
vehicle = RequestVehicle(
|
vehicle = RequestVehicle(
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
car_data=vehicle_data.car_data,
|
car_id=car_id,
|
||||||
|
car_data=car_data,
|
||||||
is_approved=vehicle_data.is_approved,
|
is_approved=vehicle_data.is_approved,
|
||||||
approved_at=datetime.utcnow() if vehicle_data.is_approved else None
|
approved_at=datetime.utcnow() if vehicle_data.is_approved else None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -218,6 +218,17 @@ export default function CarsAdminPage() {
|
|||||||
added: number;
|
added: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [requestInfo, setRequestInfo] = useState<{
|
||||||
|
user_email?: string;
|
||||||
|
user_name?: string;
|
||||||
|
maker_name?: string;
|
||||||
|
model_name?: string;
|
||||||
|
grade_name?: string;
|
||||||
|
year_from?: number;
|
||||||
|
year_to?: number;
|
||||||
|
mileage_max?: number;
|
||||||
|
fuel?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Dealer description editing state
|
// Dealer description editing state
|
||||||
const [showDescEditModal, setShowDescEditModal] = useState(false);
|
const [showDescEditModal, setShowDescEditModal] = useState(false);
|
||||||
@@ -299,6 +310,21 @@ export default function CarsAdminPage() {
|
|||||||
|
|
||||||
// Switch to Carmodoo tab
|
// Switch to Carmodoo tab
|
||||||
setActiveTab('carmodoo');
|
setActiveTab('carmodoo');
|
||||||
|
|
||||||
|
// Fetch request details for display
|
||||||
|
vehicleRequestsApi.adminGetRequestDetail(parseInt(requestId)).then(data => {
|
||||||
|
setRequestInfo({
|
||||||
|
user_email: data.request.user_email,
|
||||||
|
user_name: data.request.user_name,
|
||||||
|
maker_name: data.request.maker_name,
|
||||||
|
model_name: data.request.model_name,
|
||||||
|
grade_name: data.request.grade_name,
|
||||||
|
year_from: data.request.year_from,
|
||||||
|
year_to: data.request.year_to,
|
||||||
|
mileage_max: data.request.mileage_max,
|
||||||
|
fuel: data.request.fuel,
|
||||||
|
});
|
||||||
|
}).catch(err => console.error('Failed to load request info:', err));
|
||||||
}
|
}
|
||||||
}, [requestId]);
|
}, [requestId]);
|
||||||
|
|
||||||
@@ -1195,7 +1221,8 @@ export default function CarsAdminPage() {
|
|||||||
|
|
||||||
{/* Request Mode Banner */}
|
{/* Request Mode Banner */}
|
||||||
{requestId && (
|
{requestId && (
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6 flex items-center justify-between">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-blue-100 rounded-full p-2">
|
<div className="bg-blue-100 rounded-full p-2">
|
||||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -1203,8 +1230,14 @@ export default function CarsAdminPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-blue-800">Adding vehicles to Request #{requestId}</p>
|
<p className="font-medium text-blue-800">
|
||||||
<p className="text-sm text-blue-600">Search and select vehicles, then click "Add to Request" button.</p>
|
Adding vehicles to Request #{requestId}
|
||||||
|
{requestInfo?.user_email && (
|
||||||
|
<span className="ml-2 text-sm font-normal text-blue-600">
|
||||||
|
({requestInfo.user_email})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -1214,6 +1247,51 @@ export default function CarsAdminPage() {
|
|||||||
← Back to Requests
|
← Back to Requests
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{requestInfo && (
|
||||||
|
<div className="bg-white/50 rounded-lg p-3 grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||||
|
{requestInfo.maker_name && (
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-500">Maker:</span>
|
||||||
|
<span className="ml-1 font-medium text-blue-800">{requestInfo.maker_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{requestInfo.model_name && (
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-500">Model:</span>
|
||||||
|
<span className="ml-1 font-medium text-blue-800">{requestInfo.model_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{requestInfo.grade_name && (
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-500">Grade:</span>
|
||||||
|
<span className="ml-1 font-medium text-blue-800">{requestInfo.grade_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(requestInfo.year_from || requestInfo.year_to) && (
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-500">Year:</span>
|
||||||
|
<span className="ml-1 font-medium text-blue-800">
|
||||||
|
{requestInfo.year_from || '-'} ~ {requestInfo.year_to || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{requestInfo.mileage_max && (
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-500">Mileage:</span>
|
||||||
|
<span className="ml-1 font-medium text-blue-800">
|
||||||
|
~{Math.round(requestInfo.mileage_max / 10000)}만km
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{requestInfo.fuel && (
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-500">Fuel:</span>
|
||||||
|
<span className="ml-1 font-medium text-blue-800">{requestInfo.fuel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
@@ -2568,6 +2646,16 @@ export default function CarsAdminPage() {
|
|||||||
{selectedCar.status}
|
{selectedCar.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Sold Out</span>
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
selectedCar.soldout
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: 'bg-green-100 text-green-700'
|
||||||
|
}`}>
|
||||||
|
{selectedCar.soldout ? 'SOLD OUT' : 'Available'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Seize Count</span>
|
<span className="text-gray-600">Seize Count</span>
|
||||||
<span className={`font-medium ${(selectedCar.seize_count || 0) > 0 ? 'text-red-600' : ''}`}>
|
<span className={`font-medium ${(selectedCar.seize_count || 0) > 0 ? 'text-red-600' : ''}`}>
|
||||||
|
|||||||
@@ -410,6 +410,15 @@ export default function CarDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Soldout overlay */}
|
||||||
|
{car.soldout && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
||||||
|
<span className="bg-red-600 text-white px-6 py-3 rounded-lg font-bold text-2xl transform -rotate-12 shadow-lg">
|
||||||
|
{language === 'ko' ? '판매완료' : 'SOLD OUT'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Thumbnails */}
|
{/* Thumbnails */}
|
||||||
@@ -527,9 +536,16 @@ export default function CarDetailPage() {
|
|||||||
|
|
||||||
{/* Details */}
|
{/* Details */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800">
|
||||||
{translate(car.car_name) || `${translate(car.maker?.name) || ''} ${translate(car.model?.name) || ''}`.trim() || '-'}
|
{translate(car.car_name) || `${translate(car.maker?.name) || ''} ${translate(car.model?.name) || ''}`.trim() || '-'}
|
||||||
</h1>
|
</h1>
|
||||||
|
{car.soldout && (
|
||||||
|
<span className="px-3 py-1 bg-red-600 text-white text-sm font-bold rounded-full">
|
||||||
|
{language === 'ko' ? '판매완료' : 'SOLD OUT'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const price = formatPrice(car.final_price_krw || car.price_krw);
|
const price = formatPrice(car.final_price_krw || car.price_krw);
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface Car {
|
|||||||
dealer_description_mn?: string;
|
dealer_description_mn?: string;
|
||||||
dealer_description_ru?: string;
|
dealer_description_ru?: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
soldout?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
maker?: CarMaker;
|
maker?: CarMaker;
|
||||||
|
|||||||
Reference in New Issue
Block a user