Add staging environment documentation and minor fixes
- Document staging vs production DB separation (autonet_staging) - Add staging sync and deployment commands to CLAUDE.md - Update changelog with 2025-01-03 changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
46
CLAUDE.md
46
CLAUDE.md
@@ -134,6 +134,50 @@ ssh server2 "cd /opt/autonet/production/frontend && docker build --no-cache -t p
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Staging vs Production 환경 분리
|
||||||
|
|
||||||
|
### 데이터베이스 분리
|
||||||
|
|
||||||
|
| 환경 | DB 이름 | 용도 |
|
||||||
|
|------|---------|------|
|
||||||
|
| **Production** | `autonet` | 실서비스 데이터 |
|
||||||
|
| **Staging** | `autonet_staging` | 테스트용 데이터 (안전한 테스트) |
|
||||||
|
|
||||||
|
**DB 서버**: PostgreSQL on `192.168.0.201:5432`
|
||||||
|
|
||||||
|
### Staging 환경 설정
|
||||||
|
|
||||||
|
**Backend .env** (`/opt/autonet/staging/backend/.env`):
|
||||||
|
```env
|
||||||
|
DB_NAME=autonet_staging # Production은 autonet
|
||||||
|
```
|
||||||
|
|
||||||
|
**docker-compose.staging.yml**:
|
||||||
|
- Frontend: `NEXT_PUBLIC_API_URL=https://staging.autonetsellcar.com` (build arg)
|
||||||
|
- Backend: `.env` 파일을 볼륨 마운트로 연결
|
||||||
|
|
||||||
|
### 소스 코드 동기화
|
||||||
|
|
||||||
|
**⚠️ 중요**: Staging 디렉토리는 Git 저장소가 아님! Production과 수동 동기화 필요
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production → Staging 소스 동기화
|
||||||
|
ssh damon@192.168.0.202 "rsync -av --exclude='node_modules' --exclude='.next' /opt/autonet/production/frontend/ /opt/autonet/staging/frontend/"
|
||||||
|
|
||||||
|
# Staging Frontend 재빌드
|
||||||
|
ssh damon@192.168.0.202 "cd /opt/autonet/staging && docker compose -f docker-compose.staging.yml build --no-cache frontend-staging && docker compose -f docker-compose.staging.yml up -d frontend-staging"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Staging DB 초기 데이터 복사
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production → Staging 데이터 복사 (psql 사용)
|
||||||
|
PGPASSWORD='roskfl@1122' pg_dump -h 192.168.0.201 -U admin -d autonet --data-only -t users -t cars -t car_images -t hero_banners | \
|
||||||
|
PGPASSWORD='roskfl@1122' psql -h 192.168.0.201 -U admin -d autonet_staging
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. 프로젝트 구조
|
## 1. 프로젝트 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -790,6 +834,8 @@ Import 시 자동 번역 (Azure API)
|
|||||||
|
|
||||||
| 날짜 | 변경 내용 |
|
| 날짜 | 변경 내용 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
|
| 2025-01-03 | **Staging DB 분리**: `autonet_staging` DB 생성, Production/Staging 환경 완전 분리 |
|
||||||
|
| 2025-01-03 | **My Vehicles 기능**: 직접 구매 차량을 My Request 페이지에 표시 |
|
||||||
| 2024-12-31 | **Admin Settings 기능 추가**: Show Dealer Comment 토글, Korean Domestic + Export Customs 금액 설정 |
|
| 2024-12-31 | **Admin Settings 기능 추가**: Show Dealer Comment 토글, Korean Domestic + Export Customs 금액 설정 |
|
||||||
| 2024-12-31 | Cost 페이지에서 국내비용+수출통관비용 동적 적용 (settings API 연동) |
|
| 2024-12-31 | Cost 페이지에서 국내비용+수출통관비용 동적 적용 (settings API 연동) |
|
||||||
| 2024-12-31 | **Visitor Stats 국가별 통계 강화**: 국기 이모지 추가, 전용 Country Stats 카드 추가 |
|
| 2024-12-31 | **Visitor Stats 국가별 통계 강화**: 국기 이모지 추가, 전용 Country Stats 카드 추가 |
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
|||||||
from lxml import etree
|
from lxml import etree
|
||||||
from lxml import html as lxml_html
|
from lxml import html as lxml_html
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import Car, CarMaker, CarModel, CarImage, CarOption, CarPerformanceCheck, CarSpecification, PerformanceCheckView, CarView, User
|
from ..models import Car, CarMaker, CarModel, CarImage, CarOption, CarPerformanceCheck, CarSpecification, PerformanceCheckView, CarView, User, VehicleRequest, RequestVehicle
|
||||||
from ..models.settings import SystemSettings
|
from ..models.settings import SystemSettings
|
||||||
from ..api.auth import get_current_user, get_current_user_optional, get_current_admin_user
|
from ..api.auth import get_current_user, get_current_user_optional, get_current_admin_user
|
||||||
from ..services.cache_service import CacheService
|
from ..services.cache_service import CacheService
|
||||||
@@ -1712,7 +1712,14 @@ async def get_car_performance_check(
|
|||||||
CarView.car_id == car_id
|
CarView.car_id == car_id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
has_access = (existing_perf_view is not None) or (existing_car_view is not None)
|
# Check 3: This car was recommended to the user (paid 1 CC for recommendation)
|
||||||
|
recommended_vehicle = db.query(RequestVehicle).join(VehicleRequest).filter(
|
||||||
|
VehicleRequest.user_id == current_user.id,
|
||||||
|
RequestVehicle.car_id == car_id,
|
||||||
|
RequestVehicle.is_approved == True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
has_access = (existing_perf_view is not None) or (existing_car_view is not None) or (recommended_vehicle is not None)
|
||||||
|
|
||||||
# If no access, return only basic info (that performance check exists)
|
# If no access, return only basic info (that performance check exists)
|
||||||
if not has_access:
|
if not has_access:
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3000",
|
"start": "next start -p 3000",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"audit": "npm audit --omit=dev",
|
||||||
|
"audit:fix": "npm audit fix --omit=dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.1.1",
|
"@heroicons/react": "^2.1.1",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { vehicleRequestsApi, VehicleRequest, VehicleRequestWithVehicles } from '@/lib/api';
|
import { vehicleRequestsApi, VehicleRequest, VehicleRequestWithVehicles, adminPdfApi } from '@/lib/api';
|
||||||
|
|
||||||
export default function AdminVehicleRequestsPage() {
|
export default function AdminVehicleRequestsPage() {
|
||||||
const [requests, setRequests] = useState<VehicleRequest[]>([]);
|
const [requests, setRequests] = useState<VehicleRequest[]>([]);
|
||||||
@@ -10,6 +10,7 @@ export default function AdminVehicleRequestsPage() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
const [deletingVehicleId, setDeletingVehicleId] = useState<number | null>(null);
|
const [deletingVehicleId, setDeletingVehicleId] = useState<number | null>(null);
|
||||||
|
const [regeneratingPdfId, setRegeneratingPdfId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Load requests
|
// Load requests
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,6 +90,23 @@ export default function AdminVehicleRequestsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Regenerate PDF for a car
|
||||||
|
const regeneratePdf = async (carId: number) => {
|
||||||
|
try {
|
||||||
|
setRegeneratingPdfId(carId);
|
||||||
|
await adminPdfApi.regenerateSingle(carId);
|
||||||
|
// Reload request detail to refresh PDF status
|
||||||
|
if (selectedRequest) {
|
||||||
|
await loadRequestDetail(selectedRequest.request.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to regenerate PDF:', error);
|
||||||
|
alert('Failed to regenerate PDF. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setRegeneratingPdfId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Format date
|
// Format date
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||||
@@ -304,42 +322,89 @@ export default function AdminVehicleRequestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||||
{selectedRequest.approved_vehicles.map((vehicle) => (
|
{selectedRequest.approved_vehicles.map((vehicle) => {
|
||||||
<div key={vehicle.id} className="bg-gray-50 rounded-lg p-3 flex items-center gap-3">
|
const hasPdf = vehicle.car_data.has_pdf === true;
|
||||||
{vehicle.car_data.main_image && (
|
const isSoldout = vehicle.car_data.soldout === true;
|
||||||
<img
|
const checkNum = vehicle.car_data.check_num;
|
||||||
src={vehicle.car_data.main_image}
|
|
||||||
alt=""
|
return (
|
||||||
className="w-16 h-12 object-cover rounded"
|
<div key={vehicle.id} className={`bg-gray-50 rounded-lg p-3 flex items-center gap-3 ${isSoldout ? 'opacity-60' : ''}`}>
|
||||||
/>
|
{vehicle.car_data.main_image && (
|
||||||
)}
|
<div className="relative">
|
||||||
<div className="flex-1 min-w-0">
|
<img
|
||||||
<p className="font-medium text-sm truncate">{vehicle.car_data.car_name}</p>
|
src={vehicle.car_data.main_image}
|
||||||
<p className="text-xs text-gray-500">
|
alt=""
|
||||||
{vehicle.car_data.year} | {vehicle.car_data.mileage?.toLocaleString()}km
|
className={`w-16 h-12 object-cover rounded ${isSoldout ? 'grayscale' : ''}`}
|
||||||
</p>
|
/>
|
||||||
|
{isSoldout && (
|
||||||
|
<span className="absolute top-0 left-0 bg-red-500 text-white text-[8px] px-1 rounded">SOLD</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-sm truncate">{vehicle.car_data.car_name}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{vehicle.car_data.year} | {vehicle.car_data.mileage?.toLocaleString()}km
|
||||||
|
</p>
|
||||||
|
{/* PDF Status */}
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
{hasPdf ? (
|
||||||
|
<span className="text-xs text-green-600 flex items-center gap-1">
|
||||||
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
PDF OK
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-red-500 flex items-center gap-1">
|
||||||
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
No PDF
|
||||||
|
{vehicle.car_id && checkNum && (
|
||||||
|
<button
|
||||||
|
onClick={() => regeneratePdf(vehicle.car_id!)}
|
||||||
|
disabled={regeneratingPdfId === vehicle.car_id}
|
||||||
|
className="ml-1 text-blue-500 hover:text-blue-700 underline disabled:opacity-50"
|
||||||
|
title="Retry PDF generation"
|
||||||
|
>
|
||||||
|
{regeneratingPdfId === vehicle.car_id ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500"></div>
|
||||||
|
Generating...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Retry'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!checkNum && <span className="text-gray-400">(no check_num)</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-primary-600">
|
||||||
|
{vehicle.car_data.final_price?.toLocaleString()}만원
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteVehicleFromRequest(vehicle.id)}
|
||||||
|
disabled={deletingVehicleId === vehicle.id}
|
||||||
|
className="text-red-500 hover:text-red-700 p-1 disabled:opacity-50"
|
||||||
|
title="Remove from list"
|
||||||
|
>
|
||||||
|
{deletingVehicleId === vehicle.id ? (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-500"></div>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right flex items-center gap-2">
|
);
|
||||||
<p className="text-sm font-medium text-primary-600">
|
})}
|
||||||
{vehicle.car_data.final_price?.toLocaleString()}만원
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => deleteVehicleFromRequest(vehicle.id)}
|
|
||||||
disabled={deletingVehicleId === vehicle.id}
|
|
||||||
className="text-red-500 hover:text-red-700 p-1 disabled:opacity-50"
|
|
||||||
title="Remove from list"
|
|
||||||
>
|
|
||||||
{deletingVehicleId === vehicle.id ? (
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-500"></div>
|
|
||||||
) : (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -297,21 +297,13 @@ export default function ProfilePage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Danger Zone - Account Withdrawal */}
|
{/* Account Withdrawal - Subtle link */}
|
||||||
<div className="mt-8 bg-white rounded-xl shadow-lg p-6 border border-red-100">
|
<div className="mt-8 pt-4 border-t border-gray-100 text-center">
|
||||||
<h2 className="text-xl font-semibold text-red-600 mb-4">
|
|
||||||
{language === 'ko' ? '계정 탈퇴' : 'Account Withdrawal'}
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
{language === 'ko'
|
|
||||||
? '계정을 탈퇴하면 모든 데이터가 삭제됩니다. 이 작업은 되돌릴 수 없습니다.'
|
|
||||||
: 'Once you withdraw your account, all your data will be deleted. This action cannot be undone.'}
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowWithdrawModal(true)}
|
onClick={() => setShowWithdrawModal(true)}
|
||||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
|
className="text-xs text-gray-400 hover:text-gray-500 transition"
|
||||||
>
|
>
|
||||||
{language === 'ko' ? '계정 탈퇴 요청' : 'Request Account Withdrawal'}
|
{language === 'ko' ? '계정 탈퇴' : 'Delete Account'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user