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 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() @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 ) scheduler.start() print("[Startup] Scheduler started - Exchange rates: 11:30 AM, Visitor stats: 2:00 AM, Log cleanup: Sunday 3: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"}