Initial commit - cleaned repository
This commit is contained in:
197
services/images/backend/app/api/endpoints.py
Normal file
197
services/images/backend/app/api/endpoints.py
Normal file
@ -0,0 +1,197 @@
|
||||
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)}"
|
||||
)
|
||||
Reference in New Issue
Block a user