Files
site11/services/images/backend/app/api/endpoints.py
jungwoo choi 8866a90f65 feat: integrate MinIO storage for image caching service
- Replace file system storage with MinIO object storage
- Add MinIO cache implementation with 3-level directory structure
- Support dynamic switching between MinIO and filesystem via config
- Fix metadata encoding issue for non-ASCII URLs
- Successfully tested with various image sources including Korean URLs

All image service features working:
- Image proxy and download
- 5 size variants (thumb, card, list, detail, hero)
- WebP format conversion
- Cache hit/miss detection
- Background size generation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 07:46:12 +09:00

197 lines
6.8 KiB
Python

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)}"
)