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:
AutonetSellCar Deploy
2026-01-03 16:22:34 +09:00
parent 6ba254bfeb
commit 718c5b0474
5 changed files with 163 additions and 51 deletions

View File

@@ -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 카드 추가 |

View File

@@ -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:

View File

@@ -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",

View File

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

View File

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