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:
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
@@ -6,6 +6,7 @@ from ..models.settings import SystemSettings
|
||||
from ..schemas.settings import SystemSettingsUpdate, SystemSettingsResponse
|
||||
from .auth import get_current_user
|
||||
from ..models import User
|
||||
from ..services.car_availability_service import run_car_availability_check
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
@@ -71,3 +72,57 @@ def update_system_settings(
|
||||
db.commit()
|
||||
db.refresh(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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc
|
||||
from .config import get_settings
|
||||
from .services.exchange_rate_service import update_exchange_rates
|
||||
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
|
||||
|
||||
app_settings = get_settings()
|
||||
@@ -103,6 +104,19 @@ async def scheduled_cleanup_old_visitor_logs():
|
||||
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
|
||||
async def lifespan(app: FastAPI):
|
||||
"""앱 시작/종료 시 실행되는 lifespan 이벤트"""
|
||||
@@ -136,8 +150,28 @@ async def lifespan(app: FastAPI):
|
||||
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()
|
||||
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())
|
||||
|
||||
@@ -63,6 +63,12 @@ class SystemSettings(Base):
|
||||
withdrawal_enabled = Column(Boolean, default=True) # 출금 기능 활성화
|
||||
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())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -32,6 +32,10 @@ class SystemSettingsUpdate(BaseModel):
|
||||
withdrawal_enabled: Optional[bool] = None
|
||||
min_withdrawal_usd: Optional[float] = None
|
||||
|
||||
# 차량 판매상태 검증 설정
|
||||
car_availability_check_enabled: Optional[bool] = None
|
||||
car_availability_check_hour: Optional[int] = None
|
||||
|
||||
|
||||
class SystemSettingsResponse(BaseModel):
|
||||
"""시스템 설정 응답 스키마"""
|
||||
@@ -63,6 +67,12 @@ class SystemSettingsResponse(BaseModel):
|
||||
withdrawal_enabled: bool = True
|
||||
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
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
200
backend/app/services/car_availability_service.py
Normal file
200
backend/app/services/car_availability_service.py
Normal 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}"
|
||||
Reference in New Issue
Block a user