feat: Add car availability check feature

- Add daily scheduled check for Carmodoo car availability
- Add manual trigger button in admin settings
- Mark sold cars as soldout=True automatically
- Add settings for check time and enable/disable toggle
- Display check status and statistics in admin UI

🤖 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-05 08:01:40 +09:00
parent 1c45840c70
commit 4858965087
6 changed files with 474 additions and 2 deletions

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
@@ -6,6 +6,7 @@ from ..models.settings import SystemSettings
from ..schemas.settings import SystemSettingsUpdate, SystemSettingsResponse from ..schemas.settings import SystemSettingsUpdate, SystemSettingsResponse
from .auth import get_current_user from .auth import get_current_user
from ..models import User from ..models import User
from ..services.car_availability_service import run_car_availability_check
router = APIRouter(prefix="/settings", tags=["settings"]) router = APIRouter(prefix="/settings", tags=["settings"])
@@ -71,3 +72,57 @@ def update_system_settings(
db.commit() db.commit()
db.refresh(settings) db.refresh(settings)
return settings return settings
# ==================== Car Availability Check Endpoints ====================
@router.post("/car-availability-check")
async def trigger_car_availability_check(
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""차량 판매상태 검증 즉시 실행 (Admin)"""
# 백그라운드에서 실행
async def run_check():
from ..database import SessionLocal
check_db = SessionLocal()
try:
await run_car_availability_check(check_db)
finally:
check_db.close()
import asyncio
asyncio.create_task(run_check())
return {
"message": "Car availability check started in background",
"status": "running"
}
@router.get("/car-availability-status")
def get_car_availability_status(
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""차량 판매상태 검증 상태 조회 (Admin)"""
settings = get_or_create_settings(db)
# soldout 통계
from ..models.car import Car
total_cars = db.query(Car).filter(Car.source == 'carmodoo').count()
available_cars = db.query(Car).filter(Car.source == 'carmodoo', Car.soldout == False).count()
sold_cars = db.query(Car).filter(Car.source == 'carmodoo', Car.soldout == True).count()
return {
"check_enabled": settings.car_availability_check_enabled,
"check_hour": settings.car_availability_check_hour,
"last_check": settings.car_availability_last_check,
"last_result": settings.car_availability_last_result,
"stats": {
"total_cars": total_cars,
"available": available_cars,
"sold": sold_cars
}
}

View File

@@ -13,6 +13,7 @@ from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc
from .config import get_settings from .config import get_settings
from .services.exchange_rate_service import update_exchange_rates from .services.exchange_rate_service import update_exchange_rates
from .services.visitor_service import aggregate_daily_stats, cleanup_old_visitor_logs from .services.visitor_service import aggregate_daily_stats, cleanup_old_visitor_logs
from .services.car_availability_service import run_car_availability_check
from datetime import datetime, timedelta from datetime import datetime, timedelta
app_settings = get_settings() app_settings = get_settings()
@@ -103,6 +104,19 @@ async def scheduled_cleanup_old_visitor_logs():
db.close() db.close()
async def scheduled_car_availability_check():
"""Check car availability on Carmodoo"""
print("[Scheduler] Starting car availability check...")
db = SessionLocal()
try:
result = await run_car_availability_check(db)
print(f"[Scheduler] Car availability check completed: {result}")
except Exception as e:
print(f"[Scheduler] Car availability check failed: {e}")
finally:
db.close()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""앱 시작/종료 시 실행되는 lifespan 이벤트""" """앱 시작/종료 시 실행되는 lifespan 이벤트"""
@@ -136,8 +150,28 @@ async def lifespan(app: FastAPI):
replace_existing=True replace_existing=True
) )
# 차량 판매상태 검증 (매일 새벽 6시 - 설정에서 변경 가능)
# 동적 시간 설정을 위해 settings 확인
db = SessionLocal()
try:
from .models.settings import SystemSettings
settings = db.query(SystemSettings).first()
check_hour = settings.car_availability_check_hour if settings else 6
except:
check_hour = 6
finally:
db.close()
scheduler.add_job(
scheduled_car_availability_check,
CronTrigger(hour=check_hour, minute=0),
id="daily_car_availability_check",
name="Daily Car Availability Check",
replace_existing=True
)
scheduler.start() scheduler.start()
print("[Startup] Scheduler started - Exchange rates: 11:30 AM, Visitor stats: 2:00 AM, Log cleanup: Sunday 3:00 AM") print(f"[Startup] Scheduler started - Exchange rates: 11:30 AM, Visitor stats: 2:00 AM, Log cleanup: Sunday 3:00 AM, Car availability: {check_hour}:00 AM")
# 서버 시작 시 환율 데이터 초기화 (백그라운드에서) # 서버 시작 시 환율 데이터 초기화 (백그라운드에서)
asyncio.create_task(scheduled_update_exchange_rates()) asyncio.create_task(scheduled_update_exchange_rates())

View File

@@ -63,6 +63,12 @@ class SystemSettings(Base):
withdrawal_enabled = Column(Boolean, default=True) # 출금 기능 활성화 withdrawal_enabled = Column(Boolean, default=True) # 출금 기능 활성화
min_withdrawal_usd = Column(Float, default=10.0) # 최소 출금 금액 (USD) min_withdrawal_usd = Column(Float, default=10.0) # 최소 출금 금액 (USD)
# 차량 판매상태 검증 설정
car_availability_check_enabled = Column(Boolean, default=True) # 자동 검증 활성화
car_availability_check_hour = Column(Integer, default=6) # 검증 시간 (0-23, 기본 새벽 6시)
car_availability_last_check = Column(DateTime(timezone=True), nullable=True) # 마지막 검증 시간
car_availability_last_result = Column(String(500), nullable=True) # 마지막 검증 결과 요약
# 타임스탬프 # 타임스탬프
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -32,6 +32,10 @@ class SystemSettingsUpdate(BaseModel):
withdrawal_enabled: Optional[bool] = None withdrawal_enabled: Optional[bool] = None
min_withdrawal_usd: Optional[float] = None min_withdrawal_usd: Optional[float] = None
# 차량 판매상태 검증 설정
car_availability_check_enabled: Optional[bool] = None
car_availability_check_hour: Optional[int] = None
class SystemSettingsResponse(BaseModel): class SystemSettingsResponse(BaseModel):
"""시스템 설정 응답 스키마""" """시스템 설정 응답 스키마"""
@@ -63,6 +67,12 @@ class SystemSettingsResponse(BaseModel):
withdrawal_enabled: bool = True withdrawal_enabled: bool = True
min_withdrawal_usd: float = 10.0 min_withdrawal_usd: float = 10.0
# 차량 판매상태 검증 설정
car_availability_check_enabled: bool = True
car_availability_check_hour: int = 6
car_availability_last_check: Optional[datetime] = None
car_availability_last_result: Optional[str] = None
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None

View File

@@ -0,0 +1,200 @@
"""
Car Availability Verification Service
Checks if imported cars are still available on Carmodoo
"""
import asyncio
import httpx
from datetime import datetime
from typing import List, Tuple
from sqlalchemy.orm import Session
from lxml import etree
from ..models.car import Car
from ..models.settings import SystemSettings
# Carmodoo base URL
CARMODOO_BASE_URL = "https://dealer.carmodoo.com"
class CarAvailabilityChecker:
"""카모두에서 차량 판매상태 확인"""
def __init__(self):
self.cookies = {}
self.is_logged_in = False
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
}
async def login(self) -> bool:
"""카모두 로그인"""
import os
user_id = os.environ.get('CARMODOO_USER_ID', '')
password = os.environ.get('CARMODOO_PASSWORD', '')
if not user_id or not password:
print("[CarAvailability] No Carmodoo credentials found")
return False
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
login_url = f"{CARMODOO_BASE_URL}/member/memberLoginOk.html"
form_data = {
'userId': user_id,
'userPwd': password,
'auto_login': 'N',
}
response = await client.post(
login_url,
data=form_data,
headers=self.headers
)
self.cookies = dict(response.cookies)
if 'JSESSIONID' in self.cookies or response.status_code == 200:
self.is_logged_in = True
print("[CarAvailability] Login successful")
return True
else:
print("[CarAvailability] Login failed")
return False
except Exception as e:
print(f"[CarAvailability] Login error: {e}")
return False
async def check_car_availability(self, source_id: str) -> bool:
"""
특정 차량이 카모두에서 아직 판매중인지 확인
source_id: 카모두 차량 번호 (c_cNo)
Returns: True if still available, False if sold/removed
"""
if not self.is_logged_in:
await self.login()
try:
async with httpx.AsyncClient(timeout=30.0, cookies=self.cookies) as client:
# 차량 상세 페이지 직접 접근
detail_url = f"{CARMODOO_BASE_URL}/car/carPopView.html"
params = {'c_cNo': source_id}
response = await client.get(
detail_url,
params=params,
headers=self.headers
)
if response.status_code != 200:
return False
# EUC-KR 디코딩
try:
content = response.content.decode('euc-kr', errors='replace')
except:
content = response.text
# 판매완료/삭제 표시 확인
sold_indicators = [
'판매완료',
'판매 완료',
'삭제된 차량',
'존재하지 않는',
'해당 차량이 없습니다',
'매물이 존재하지',
]
for indicator in sold_indicators:
if indicator in content:
return False
# 차량 정보가 있으면 판매중
if 'carViewWrap' in content or '차량정보' in content or '차량번호' in content:
return True
# 기본적으로 응답이 있으면 판매중으로 간주
return len(content) > 500
except Exception as e:
print(f"[CarAvailability] Error checking car {source_id}: {e}")
# 에러 시 기존 상태 유지 (판매중으로 간주)
return True
async def verify_all_cars(self, db: Session) -> Tuple[int, int, int]:
"""
모든 활성 차량의 판매상태 확인
Returns: (total_checked, still_available, sold_count)
"""
# soldout=False인 차량만 확인
cars = db.query(Car).filter(
Car.soldout == False,
Car.source == 'carmodoo'
).all()
if not cars:
return (0, 0, 0)
# 로그인
if not self.is_logged_in:
login_success = await self.login()
if not login_success:
print("[CarAvailability] Failed to login, aborting verification")
return (0, 0, 0)
total = len(cars)
available = 0
sold = 0
print(f"[CarAvailability] Starting verification of {total} cars...")
for i, car in enumerate(cars):
try:
is_available = await self.check_car_availability(car.source_id)
if is_available:
available += 1
else:
sold += 1
car.soldout = True
print(f"[CarAvailability] Car {car.id} ({car.car_name}) marked as sold")
# Rate limiting - 0.5초 대기
if i < total - 1:
await asyncio.sleep(0.5)
# 진행상황 로그 (10개마다)
if (i + 1) % 10 == 0:
print(f"[CarAvailability] Progress: {i + 1}/{total}")
except Exception as e:
print(f"[CarAvailability] Error checking car {car.id}: {e}")
available += 1 # 에러 시 판매중으로 간주
# DB 커밋
db.commit()
print(f"[CarAvailability] Verification complete: {total} checked, {available} available, {sold} sold")
return (total, available, sold)
async def run_car_availability_check(db: Session) -> str:
"""
차량 판매상태 검증 실행 및 결과 저장
Returns: 결과 요약 문자열
"""
checker = CarAvailabilityChecker()
total, available, sold = await checker.verify_all_cars(db)
# 설정에 결과 저장
settings = db.query(SystemSettings).first()
if settings:
settings.car_availability_last_check = datetime.now()
settings.car_availability_last_result = f"Checked: {total}, Available: {available}, Sold: {sold}"
db.commit()
return f"Checked: {total}, Available: {available}, Sold: {sold}"

View File

@@ -33,6 +33,23 @@ interface SystemSettings {
event_cc_validity_months: number; event_cc_validity_months: number;
withdrawal_enabled: boolean; withdrawal_enabled: boolean;
min_withdrawal_usd: number; min_withdrawal_usd: number;
// Car availability check settings
car_availability_check_enabled: boolean;
car_availability_check_hour: number;
car_availability_last_check: string | null;
car_availability_last_result: string | null;
}
interface CarAvailabilityStatus {
check_enabled: boolean;
check_hour: number;
last_check: string | null;
last_result: string | null;
stats: {
total_cars: number;
available: number;
sold: number;
};
} }
interface ExchangeRateWeights { interface ExchangeRateWeights {
@@ -50,6 +67,8 @@ export default function SettingsPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [savingExchangeRates, setSavingExchangeRates] = useState(false); const [savingExchangeRates, setSavingExchangeRates] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [carAvailabilityStatus, setCarAvailabilityStatus] = useState<CarAvailabilityStatus | null>(null);
const [runningCheck, setRunningCheck] = useState(false);
const [exchangeRateWeights, setExchangeRateWeights] = useState<ExchangeRateWeights>({ const [exchangeRateWeights, setExchangeRateWeights] = useState<ExchangeRateWeights>({
usd: 0, usd: 0,
mnt: 0, mnt: 0,
@@ -84,11 +103,15 @@ export default function SettingsPage() {
event_cc_validity_months: 6, event_cc_validity_months: 6,
withdrawal_enabled: true, withdrawal_enabled: true,
min_withdrawal_usd: 10.0, min_withdrawal_usd: 10.0,
// Car availability check
car_availability_check_enabled: true,
car_availability_check_hour: 6,
}); });
useEffect(() => { useEffect(() => {
fetchSettings(); fetchSettings();
fetchExchangeRateWeights(); fetchExchangeRateWeights();
fetchCarAvailabilityStatus();
}, []); }, []);
const fetchSettings = async () => { const fetchSettings = async () => {
@@ -123,6 +146,9 @@ export default function SettingsPage() {
event_cc_validity_months: data.event_cc_validity_months ?? 6, event_cc_validity_months: data.event_cc_validity_months ?? 6,
withdrawal_enabled: data.withdrawal_enabled ?? true, withdrawal_enabled: data.withdrawal_enabled ?? true,
min_withdrawal_usd: data.min_withdrawal_usd ?? 10.0, min_withdrawal_usd: data.min_withdrawal_usd ?? 10.0,
// Car availability check
car_availability_check_enabled: data.car_availability_check_enabled ?? true,
car_availability_check_hour: data.car_availability_check_hour ?? 6,
}); });
} }
} catch (error) { } catch (error) {
@@ -145,6 +171,55 @@ export default function SettingsPage() {
} }
}; };
const fetchCarAvailabilityStatus = async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/api/settings/car-availability-status`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setCarAvailabilityStatus(data);
}
} catch (error) {
console.error('Failed to fetch car availability status:', error);
}
};
const triggerCarAvailabilityCheck = async () => {
setRunningCheck(true);
setMessage(null);
try {
const token = localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/api/settings/car-availability-check`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
setMessage({ type: 'success', text: 'Car availability check started in background. Refresh to see results.' });
// Poll for status updates
setTimeout(() => {
fetchCarAvailabilityStatus();
setRunningCheck(false);
}, 5000);
} else {
const error = await response.json();
setMessage({ type: 'error', text: error.detail || 'Failed to start car availability check' });
setRunningCheck(false);
}
} catch (error) {
console.error('Failed to trigger car availability check:', error);
setMessage({ type: 'error', text: 'Failed to start car availability check' });
setRunningCheck(false);
}
};
const saveExchangeRateWeights = async () => { const saveExchangeRateWeights = async () => {
setSavingExchangeRates(true); setSavingExchangeRates(true);
setMessage(null); setMessage(null);
@@ -704,6 +779,98 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Car Availability Check Settings */}
<div className="bg-white rounded-xl shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
<span>Car Availability Check</span>
</h2>
<div className="space-y-4">
{/* Enable/Disable Toggle */}
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.car_availability_check_enabled}
onChange={(e) => setFormData(prev => ({ ...prev, car_availability_check_enabled: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
<div>
<span className="text-sm font-medium text-gray-700">Enable Daily Auto Check</span>
<p className="text-sm text-gray-500">Import된 Carmodoo </p>
</div>
</div>
{/* Check Hour Setting */}
<div className="max-w-xs">
<label className="block text-sm font-medium text-gray-700 mb-1">
Daily Check Time (Hour, 0-23)
</label>
<input
type="number"
min="0"
max="23"
value={formData.car_availability_check_hour}
onChange={(e) => setFormData(prev => ({ ...prev, car_availability_check_hour: parseInt(e.target.value) || 6 }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<p className="mt-1 text-sm text-gray-500"> {formData.car_availability_check_hour}:00 </p>
</div>
{/* Manual Check Button */}
<div className="pt-4 border-t">
<button
type="button"
onClick={triggerCarAvailabilityCheck}
disabled={runningCheck}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{runningCheck && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
)}
{runningCheck ? 'Checking...' : 'Run Check Now'}
</button>
<p className="mt-2 text-sm text-gray-500"> ( )</p>
</div>
{/* Status Display */}
{carAvailabilityStatus && (
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
<h3 className="font-medium text-gray-800 mb-3">Check Status</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div className="bg-white p-3 rounded-lg shadow-sm">
<p className="text-gray-500">Total Cars</p>
<p className="text-xl font-bold text-gray-800">{carAvailabilityStatus.stats.total_cars}</p>
</div>
<div className="bg-white p-3 rounded-lg shadow-sm">
<p className="text-gray-500">Available</p>
<p className="text-xl font-bold text-green-600">{carAvailabilityStatus.stats.available}</p>
</div>
<div className="bg-white p-3 rounded-lg shadow-sm">
<p className="text-gray-500">Sold</p>
<p className="text-xl font-bold text-red-600">{carAvailabilityStatus.stats.sold}</p>
</div>
<div className="bg-white p-3 rounded-lg shadow-sm">
<p className="text-gray-500">Last Check</p>
<p className="text-sm font-medium text-gray-800">
{carAvailabilityStatus.last_check
? new Date(carAvailabilityStatus.last_check).toLocaleString('ko-KR')
: 'Never'}
</p>
</div>
</div>
{carAvailabilityStatus.last_result && (
<div className="mt-3 p-2 bg-blue-50 rounded text-sm text-blue-700">
Last Result: {carAvailabilityStatus.last_result}
</div>
)}
</div>
)}
</div>
</div>
{/* Submit Button */} {/* Submit Button */}
<div className="flex justify-end"> <div className="flex justify-end">
<button <button