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 ..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
}
}

View File

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

View File

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

View File

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

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}"