- 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>
251 lines
8.6 KiB
Python
251 lines
8.6 KiB
Python
from fastapi import FastAPI, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.responses import RedirectResponse
|
|
from contextlib import asynccontextmanager
|
|
import os
|
|
import asyncio
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
from .database import engine, Base, SessionLocal
|
|
from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor, sns_share
|
|
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()
|
|
|
|
|
|
class TrailingSlashMiddleware(BaseHTTPMiddleware):
|
|
"""
|
|
Middleware to normalize trailing slashes on API paths.
|
|
Uses redirect to strip trailing slashes from non-root routes.
|
|
|
|
Routes defined with "/" (like /hero-banners/) keep trailing slash.
|
|
Routes defined without "/" (like /cars) get redirected.
|
|
"""
|
|
# Routes that are defined WITH trailing slash (router.get("/"))
|
|
TRAILING_SLASH_ROUTES = {
|
|
"/api/hero-banners/",
|
|
"/api/settings/",
|
|
"/api/notifications/",
|
|
"/api/vehicle-requests/",
|
|
}
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
path = request.url.path
|
|
|
|
# Skip if it's a known trailing-slash route
|
|
if path in self.TRAILING_SLASH_ROUTES:
|
|
return await call_next(request)
|
|
|
|
# Redirect trailing slash from other /api/* paths
|
|
if path.startswith("/api/") and path.endswith("/") and len(path) > 5:
|
|
new_path = path.rstrip("/")
|
|
if request.url.query:
|
|
new_url = f"{new_path}?{request.url.query}"
|
|
else:
|
|
new_url = new_path
|
|
return RedirectResponse(url=new_url, status_code=307)
|
|
|
|
return await call_next(request)
|
|
|
|
|
|
# Create tables
|
|
Base.metadata.create_all(bind=engine)
|
|
|
|
# APScheduler 설정
|
|
scheduler = AsyncIOScheduler()
|
|
|
|
|
|
async def scheduled_update_exchange_rates():
|
|
"""스케줄된 환율 업데이트 작업"""
|
|
print("[Scheduler] Starting daily exchange rate update...")
|
|
db = SessionLocal()
|
|
try:
|
|
result = await update_exchange_rates(db, force=True)
|
|
print(f"[Scheduler] Exchange rate update completed: {result}")
|
|
except Exception as e:
|
|
print(f"[Scheduler] Exchange rate update failed: {e}")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
async def scheduled_aggregate_visitor_stats():
|
|
"""Aggregate yesterday's visitor stats"""
|
|
print("[Scheduler] Aggregating visitor stats...")
|
|
db = SessionLocal()
|
|
try:
|
|
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
|
result = aggregate_daily_stats(db, yesterday)
|
|
if result:
|
|
print(f"[Scheduler] Visitor stats aggregated for {yesterday}")
|
|
else:
|
|
print(f"[Scheduler] No visitor data for {yesterday}")
|
|
except Exception as e:
|
|
print(f"[Scheduler] Visitor stats aggregation failed: {e}")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
async def scheduled_cleanup_old_visitor_logs():
|
|
"""Delete visitor logs older than 90 days"""
|
|
print("[Scheduler] Cleaning up old visitor logs...")
|
|
db = SessionLocal()
|
|
try:
|
|
deleted = cleanup_old_visitor_logs(db, days=90)
|
|
print(f"[Scheduler] Deleted {deleted} old visitor logs")
|
|
except Exception as e:
|
|
print(f"[Scheduler] Visitor log cleanup failed: {e}")
|
|
finally:
|
|
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 이벤트"""
|
|
# 시작 시
|
|
print("[Startup] Initializing scheduler...")
|
|
|
|
# 환율 업데이트 스케줄 등록 (매일 오전 11시 30분 - 수출입은행 11시경 업데이트)
|
|
scheduler.add_job(
|
|
scheduled_update_exchange_rates,
|
|
CronTrigger(hour=11, minute=30),
|
|
id="daily_exchange_rate_update",
|
|
name="Daily Exchange Rate Update",
|
|
replace_existing=True
|
|
)
|
|
|
|
# 방문자 통계 집계 (매일 새벽 2시)
|
|
scheduler.add_job(
|
|
scheduled_aggregate_visitor_stats,
|
|
CronTrigger(hour=2, minute=0),
|
|
id="daily_visitor_stats_aggregation",
|
|
name="Daily Visitor Stats Aggregation",
|
|
replace_existing=True
|
|
)
|
|
|
|
# 오래된 방문자 로그 정리 (매주 일요일 새벽 3시)
|
|
scheduler.add_job(
|
|
scheduled_cleanup_old_visitor_logs,
|
|
CronTrigger(day_of_week='sun', hour=3, minute=0),
|
|
id="weekly_visitor_log_cleanup",
|
|
name="Weekly Visitor Log Cleanup",
|
|
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(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())
|
|
|
|
yield
|
|
|
|
# 종료 시
|
|
print("[Shutdown] Stopping scheduler...")
|
|
scheduler.shutdown()
|
|
|
|
|
|
app = FastAPI(
|
|
title="AutonetSellCar API",
|
|
description="AutonetSellCar - Used Car Export Platform API",
|
|
version="1.0.0",
|
|
lifespan=lifespan
|
|
)
|
|
|
|
# Trailing slash middleware (must be added before CORS)
|
|
app.add_middleware(TrailingSlashMiddleware)
|
|
|
|
# CORS - credentials=True requires explicit origins (not "*")
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[
|
|
"http://localhost:3000",
|
|
"http://127.0.0.1:3000",
|
|
"http://localhost:8000",
|
|
"http://192.168.0.202:3000", # Production
|
|
"http://192.168.0.202:3001", # Staging
|
|
"http://192.168.0.202:8001", # Staging backend
|
|
"https://autonetsellcar.com", # Production domain
|
|
"http://autonetsellcar.com", # Production (HTTP redirect)
|
|
"https://staging.autonetsellcar.com", # Staging domain
|
|
"http://staging.autonetsellcar.com", # Staging (HTTP redirect)
|
|
],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Static files for uploads
|
|
os.makedirs("./uploads/hero-banners", exist_ok=True)
|
|
app.mount("/uploads", StaticFiles(directory="./uploads"), name="uploads")
|
|
|
|
# Routes
|
|
app.include_router(cars.router, prefix="/api")
|
|
app.include_router(auth.router, prefix="/api")
|
|
app.include_router(inquiries.router, prefix="/api")
|
|
app.include_router(hero_banners.router, prefix="/api")
|
|
app.include_router(carmodoo.router, prefix="/api")
|
|
app.include_router(translations.router, prefix="/api")
|
|
app.include_router(cc.router, prefix="/api")
|
|
app.include_router(settings.router, prefix="/api")
|
|
app.include_router(vehicle_requests.router, prefix="/api")
|
|
app.include_router(dealer.router, prefix="/api")
|
|
app.include_router(vehicle_share.router, prefix="/api")
|
|
app.include_router(withdrawal.router, prefix="/api")
|
|
app.include_router(referral.router, prefix="/api")
|
|
app.include_router(notification.router, prefix="/api")
|
|
app.include_router(dashboard.router, prefix="/api")
|
|
app.include_router(push.router, prefix="/api")
|
|
app.include_router(exchange_rate.router)
|
|
app.include_router(verification.router, prefix="/api")
|
|
app.include_router(visitor.router, prefix="/api")
|
|
app.include_router(sns_share.router, prefix="/api")
|
|
|
|
|
|
@app.get("/")
|
|
def root():
|
|
return {"message": "AutonetSellCar API", "version": "1.0.0"}
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"status": "healthy"}
|