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.config import settings # MinIO 사용 여부에 따라 적절한 캐시 모듈 선택 if settings.use_minio: from ..core.minio_cache import cache else: from ..core.cache import cache 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() # 디렉토리 구조 통계 추가 (MinIO 또는 파일시스템) 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)}" )