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. 프로젝트 구조
|
||||
|
||||
```
|
||||
@@ -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 | Cost 페이지에서 국내비용+수출통관비용 동적 적용 (settings API 연동) |
|
||||
| 2024-12-31 | **Visitor Stats 국가별 통계 강화**: 국기 이모지 추가, 전용 Country Stats 카드 추가 |
|
||||
|
||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
||||
from lxml import etree
|
||||
from lxml import html as lxml_html
|
||||
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 ..api.auth import get_current_user, get_current_user_optional, get_current_admin_user
|
||||
from ..services.cache_service import CacheService
|
||||
@@ -1712,7 +1712,14 @@ async def get_car_performance_check(
|
||||
CarView.car_id == car_id
|
||||
).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 not has_access:
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"audit": "npm audit --omit=dev",
|
||||
"audit:fix": "npm audit fix --omit=dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.1.1",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { vehicleRequestsApi, VehicleRequest, VehicleRequestWithVehicles } from '@/lib/api';
|
||||
import { vehicleRequestsApi, VehicleRequest, VehicleRequestWithVehicles, adminPdfApi } from '@/lib/api';
|
||||
|
||||
export default function AdminVehicleRequestsPage() {
|
||||
const [requests, setRequests] = useState<VehicleRequest[]>([]);
|
||||
@@ -10,6 +10,7 @@ export default function AdminVehicleRequestsPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [deletingVehicleId, setDeletingVehicleId] = useState<number | null>(null);
|
||||
const [regeneratingPdfId, setRegeneratingPdfId] = useState<number | null>(null);
|
||||
|
||||
// Load requests
|
||||
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
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||
@@ -304,20 +322,66 @@ export default function AdminVehicleRequestsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{selectedRequest.approved_vehicles.map((vehicle) => (
|
||||
<div key={vehicle.id} className="bg-gray-50 rounded-lg p-3 flex items-center gap-3">
|
||||
{selectedRequest.approved_vehicles.map((vehicle) => {
|
||||
const hasPdf = vehicle.car_data.has_pdf === true;
|
||||
const isSoldout = vehicle.car_data.soldout === true;
|
||||
const checkNum = vehicle.car_data.check_num;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<img
|
||||
src={vehicle.car_data.main_image}
|
||||
alt=""
|
||||
className="w-16 h-12 object-cover rounded"
|
||||
className={`w-16 h-12 object-cover rounded ${isSoldout ? 'grayscale' : ''}`}
|
||||
/>
|
||||
{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">
|
||||
@@ -339,7 +403,8 @@ export default function AdminVehicleRequestsPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -297,21 +297,13 @@ export default function ProfilePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone - Account Withdrawal */}
|
||||
<div className="mt-8 bg-white rounded-xl shadow-lg p-6 border border-red-100">
|
||||
<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>
|
||||
{/* Account Withdrawal - Subtle link */}
|
||||
<div className="mt-8 pt-4 border-t border-gray-100 text-center">
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user