from fastapi import APIRouter, Query, HTTPException, Body from fastapi.responses import Response from typing import Optional, Dict import mimetypes from pathlib import Path import hashlib from ..core.cache import cache from ..core.config import settings router = APIRouter() @router.get("/image") async def get_image( url: str = Query(..., description="원본 이미지 URL"), size: Optional[str] = Query(None, description="이미지 크기 (thumb, card, list, detail, hero)") ): """ 이미지 프록시 엔드포인트 - 외부 URL의 이미지를 가져와서 캐싱 - 선택적으로 리사이징 및 최적화 - WebP 포맷으로 자동 변환 (설정에 따라) """ try: # 캐시 확인 cached_data = await cache.get(url, size) if cached_data: # 캐시된 이미지 반환 # SVG 체크 if url.lower().endswith('.svg') or cache._is_svg(cached_data): content_type = 'image/svg+xml' # GIF 체크 (GIF는 WebP로 변환하지 않음) elif url.lower().endswith('.gif'): content_type = 'image/gif' # WebP 변환이 활성화된 경우 항상 WebP로 제공 (GIF 제외) elif settings.convert_to_webp and size: content_type = 'image/webp' else: content_type = mimetypes.guess_type(url)[0] or 'image/jpeg' return Response( content=cached_data, media_type=content_type, headers={ "Cache-Control": f"public, max-age={86400 * 7}", # 7일 브라우저 캐시 "X-Cache": "HIT", "X-Image-Format": content_type.split('/')[-1].upper() } ) # 캐시 미스 - 이미지 다운로드 image_data = await cache.download_image(url) # URL에서 MIME 타입 추측 guessed_type = mimetypes.guess_type(url)[0] # SVG 확장자 체크 (mimetypes가 SVG를 제대로 인식하지 못할 수 있음) if url.lower().endswith('.svg') or cache._is_svg(image_data): content_type = 'image/svg+xml' # GIF 체크 elif url.lower().endswith('.gif') or (guessed_type and 'gif' in guessed_type.lower()): content_type = 'image/gif' else: content_type = guessed_type or 'image/jpeg' # 리사이징 및 최적화 (SVG와 GIF는 특별 처리) if size and content_type != 'image/svg+xml': # GIF는 특별 처리 if content_type == 'image/gif': image_data, content_type = cache._process_gif(image_data, settings.thumbnail_sizes[size]) else: image_data, content_type = cache.resize_and_optimize_image(image_data, size) # 캐시에 저장 await cache.set(url, image_data, size) # 백그라운드에서 다른 크기들도 생성하도록 트리거 await cache.trigger_background_generation(url) # 이미지 반환 return Response( content=image_data, media_type=content_type, headers={ "Cache-Control": f"public, max-age={86400 * 7}", "X-Cache": "MISS", "X-Image-Format": content_type.split('/')[-1].upper() } ) except HTTPException: raise except Exception as e: import traceback print(f"Error processing image from {url}: {str(e)}") traceback.print_exc() # 403 에러를 명확히 처리 if "403" in str(e): raise HTTPException( status_code=403, detail=f"이미지 접근 거부됨: {url}" ) raise HTTPException( status_code=500, detail=f"이미지 처리 실패: {str(e)}" ) @router.get("/stats") async def get_stats(): """캐시 통계 정보""" cache_size = await cache.get_cache_size() # 디렉토리 구조 통계 추가 dir_stats = await cache.get_directory_stats() return { "cache_size_gb": round(cache_size, 2), "max_cache_size_gb": settings.max_cache_size_gb, "cache_usage_percent": round((cache_size / settings.max_cache_size_gb) * 100, 2), "directory_stats": dir_stats } @router.post("/cleanup") async def cleanup_cache(): """오래된 캐시 정리""" await cache.cleanup_old_cache() return {"message": "캐시 정리 완료"} @router.post("/cache/delete") async def delete_cache(request: Dict = Body(...)): """특정 URL의 캐시 삭제""" url = request.get("url") if not url: raise HTTPException(status_code=400, detail="URL이 필요합니다") try: # URL의 모든 크기 버전 삭제 sizes = ["thumb", "card", "list", "detail", "hero", None] # None은 원본 deleted_count = 0 for size in sizes: # 캐시 경로 계산 url_hash = hashlib.md5(url.encode()).hexdigest() # 3단계 디렉토리 구조 level1 = url_hash[:2] level2 = url_hash[2:4] level3 = url_hash[4:6] # 크기별 파일명 if size: patterns = [ f"{url_hash}_{size}.webp", f"{url_hash}_{size}.jpg", f"{url_hash}_{size}.jpeg", f"{url_hash}_{size}.png", f"{url_hash}_{size}.gif" ] else: patterns = [ f"{url_hash}", f"{url_hash}.jpg", f"{url_hash}.jpeg", f"{url_hash}.png", f"{url_hash}.gif", f"{url_hash}.webp" ] # 각 패턴에 대해 파일 삭제 시도 for filename in patterns: cache_path = settings.cache_dir / level1 / level2 / level3 / filename if cache_path.exists(): cache_path.unlink() deleted_count += 1 print(f"✅ 캐시 파일 삭제: {cache_path}") return { "status": "success", "message": f"{deleted_count}개의 캐시 파일이 삭제되었습니다", "url": url } except Exception as e: print(f"❌ 캐시 삭제 오류: {e}") raise HTTPException( status_code=500, detail=f"캐시 삭제 실패: {str(e)}" )