From 485896508746adbc9f514fd2b856698866b196fa Mon Sep 17 00:00:00 2001 From: AutonetSellCar Deploy Date: Mon, 5 Jan 2026 08:01:40 +0900 Subject: [PATCH] feat: Add car availability check feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/api/settings.py | 57 ++++- backend/app/main.py | 36 +++- backend/app/models/settings.py | 6 + backend/app/schemas/settings.py | 10 + .../app/services/car_availability_service.py | 200 ++++++++++++++++++ frontend/src/app/admin/settings/page.tsx | 167 +++++++++++++++ 6 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 backend/app/services/car_availability_service.py diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 5263d7d..53539b8 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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 + } + } diff --git a/backend/app/main.py b/backend/app/main.py index f2c6702..574beeb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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()) diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index ca818f9..51f0bb2 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -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()) diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index a9f941d..1ba42f9 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -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 diff --git a/backend/app/services/car_availability_service.py b/backend/app/services/car_availability_service.py new file mode 100644 index 0000000..f68af12 --- /dev/null +++ b/backend/app/services/car_availability_service.py @@ -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}" diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx index 431bf74..12b26cf 100644 --- a/frontend/src/app/admin/settings/page.tsx +++ b/frontend/src/app/admin/settings/page.tsx @@ -33,6 +33,23 @@ interface SystemSettings { event_cc_validity_months: number; withdrawal_enabled: boolean; 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 { @@ -50,6 +67,8 @@ export default function SettingsPage() { const [saving, setSaving] = useState(false); const [savingExchangeRates, setSavingExchangeRates] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [carAvailabilityStatus, setCarAvailabilityStatus] = useState(null); + const [runningCheck, setRunningCheck] = useState(false); const [exchangeRateWeights, setExchangeRateWeights] = useState({ usd: 0, mnt: 0, @@ -84,11 +103,15 @@ export default function SettingsPage() { event_cc_validity_months: 6, withdrawal_enabled: true, min_withdrawal_usd: 10.0, + // Car availability check + car_availability_check_enabled: true, + car_availability_check_hour: 6, }); useEffect(() => { fetchSettings(); fetchExchangeRateWeights(); + fetchCarAvailabilityStatus(); }, []); const fetchSettings = async () => { @@ -123,6 +146,9 @@ export default function SettingsPage() { event_cc_validity_months: data.event_cc_validity_months ?? 6, withdrawal_enabled: data.withdrawal_enabled ?? true, 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) { @@ -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 () => { setSavingExchangeRates(true); setMessage(null); @@ -704,6 +779,98 @@ export default function SettingsPage() { + {/* Car Availability Check Settings */} +
+

+ Car Availability Check +

+ +
+ {/* Enable/Disable Toggle */} +
+ +
+ Enable Daily Auto Check +

Import๋œ ์ฐจ๋Ÿ‰์˜ Carmodoo ํŒ๋งค์ƒํƒœ๋ฅผ ๋งค์ผ ์ž๋™ ๊ฒ€์ฆ

+
+
+ + {/* Check Hour Setting */} +
+ + 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" + /> +

๋งค์ผ {formData.car_availability_check_hour}:00์— ๊ฒ€์ฆ ์‹คํ–‰

+
+ + {/* Manual Check Button */} +
+ +

์ฆ‰์‹œ ๋ชจ๋“  ์ฐจ๋Ÿ‰์˜ ํŒ๋งค์ƒํƒœ๋ฅผ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค (๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ์‹คํ–‰)

+
+ + {/* Status Display */} + {carAvailabilityStatus && ( +
+

Check Status

+
+
+

Total Cars

+

{carAvailabilityStatus.stats.total_cars}

+
+
+

Available

+

{carAvailabilityStatus.stats.available}

+
+
+

Sold

+

{carAvailabilityStatus.stats.sold}

+
+
+

Last Check

+

+ {carAvailabilityStatus.last_check + ? new Date(carAvailabilityStatus.last_check).toLocaleString('ko-KR') + : 'Never'} +

+
+
+ {carAvailabilityStatus.last_result && ( +
+ Last Result: {carAvailabilityStatus.last_result} +
+ )} +
+ )} +
+
+ {/* Submit Button */}