From 4451170466fd01fe5b05100a0b68851dd2be5719 Mon Sep 17 00:00:00 2001 From: jungwoo choi Date: Wed, 10 Sep 2025 16:49:48 +0900 Subject: [PATCH] Step 6: Images Service Integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Integrated image-service from site00 as second microservice - Maintained proxy and caching functionality - Added Images service to docker-compose - Configured Console API Gateway routing to Images - Updated environment variables in .env - Successfully tested image proxy endpoints Services now running: - Console (API Gateway) - Users Service - Images Service (proxy & cache) - MongoDB & Redis Next: Kafka event system implementation ๐Ÿค– Generated with Claude Code Co-Authored-By: Claude --- console/backend/main.py | 47 +- docker-compose.yml | 27 +- docs/TEST_AUTH.md | 170 ++++ services/images/backend/Dockerfile | 26 + services/images/backend/app/__init__.py | 0 services/images/backend/app/api/endpoints.py | 192 +++++ .../backend/app/core/background_tasks.py | 91 ++ services/images/backend/app/core/cache.py | 796 ++++++++++++++++++ services/images/backend/app/core/config.py | 46 + services/images/backend/main.py | 65 ++ services/images/backend/requirements.txt | 11 + 11 files changed, 1469 insertions(+), 2 deletions(-) create mode 100644 docs/TEST_AUTH.md create mode 100644 services/images/backend/Dockerfile create mode 100644 services/images/backend/app/__init__.py create mode 100644 services/images/backend/app/api/endpoints.py create mode 100644 services/images/backend/app/core/background_tasks.py create mode 100644 services/images/backend/app/core/cache.py create mode 100644 services/images/backend/app/core/config.py create mode 100644 services/images/backend/main.py create mode 100644 services/images/backend/requirements.txt diff --git a/console/backend/main.py b/console/backend/main.py index 95244c0..0f22f9a 100644 --- a/console/backend/main.py +++ b/console/backend/main.py @@ -21,6 +21,7 @@ app = FastAPI( # Service URLs from environment USERS_SERVICE_URL = os.getenv("USERS_SERVICE_URL", "http://users-backend:8000") +IMAGES_SERVICE_URL = os.getenv("IMAGES_SERVICE_URL", "http://images-backend:8000") # CORS middleware app.add_middleware( @@ -111,9 +112,16 @@ async def system_status(): except: services_status["users"] = "offline" + # Check Images service + try: + async with httpx.AsyncClient() as client: + response = await client.get(f"{IMAGES_SERVICE_URL}/health", timeout=2.0) + services_status["images"] = "online" if response.status_code == 200 else "error" + except: + services_status["images"] = "offline" + # Other services (not yet implemented) services_status["oauth"] = "pending" - services_status["images"] = "pending" services_status["applications"] = "pending" services_status["data"] = "pending" services_status["statistics"] = "pending" @@ -133,6 +141,43 @@ async def protected_route(current_user = Depends(get_current_user)): "user": current_user.username } +# API Gateway - Route to Images service +@app.api_route("/api/images/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) +async def proxy_to_images(path: str, request: Request): + """Proxy requests to Images service (public for image proxy)""" + try: + async with httpx.AsyncClient() as client: + # Build the target URL + url = f"{IMAGES_SERVICE_URL}/api/v1/{path}" + + # Get request body if exists + body = None + if request.method in ["POST", "PUT", "PATCH"]: + body = await request.body() + + # Forward the request + response = await client.request( + method=request.method, + url=url, + headers={ + key: value for key, value in request.headers.items() + if key.lower() not in ["host", "content-length"] + }, + content=body, + params=request.query_params + ) + + # Return the response + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers) + ) + except httpx.ConnectError: + raise HTTPException(status_code=503, detail="Images service unavailable") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + # API Gateway - Route to Users service @app.api_route("/api/users/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) async def proxy_to_users(path: str, request: Request, current_user = Depends(get_current_user)): diff --git a/docker-compose.yml b/docker-compose.yml index 581b71b..c595d67 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,6 +54,30 @@ services: depends_on: - mongodb + images-backend: + build: + context: ./services/images/backend + dockerfile: Dockerfile + container_name: ${COMPOSE_PROJECT_NAME}_images_backend + ports: + - "${IMAGES_SERVICE_PORT}:8000" + environment: + - ENV=${ENV} + - PORT=8000 + - REDIS_URL=${REDIS_URL} + - MONGODB_URL=${MONGODB_URL} + - CACHE_DIR=/app/cache + - CONVERT_TO_WEBP=true + volumes: + - ./services/images/backend:/app + - images_cache:/app/cache + networks: + - site11_network + restart: unless-stopped + depends_on: + - redis + - mongodb + mongodb: image: mongo:7.0 container_name: ${COMPOSE_PROJECT_NAME}_mongodb @@ -97,4 +121,5 @@ networks: volumes: mongodb_data: mongodb_config: - redis_data: \ No newline at end of file + redis_data: + images_cache: \ No newline at end of file diff --git a/docs/TEST_AUTH.md b/docs/TEST_AUTH.md new file mode 100644 index 0000000..216951a --- /dev/null +++ b/docs/TEST_AUTH.md @@ -0,0 +1,170 @@ +# ์ธ์ฆ ์‹œ์Šคํ…œ ํ…Œ์ŠคํŠธ ๊ฐ€์ด๋“œ + +## ํ…Œ์ŠคํŠธ ๊ณ„์ • +- **๊ด€๋ฆฌ์ž**: admin / admin123 +- **์ผ๋ฐ˜ ์‚ฌ์šฉ์ž**: user / user123 + +## 1. Terminal์—์„œ ํ…Œ์ŠคํŠธ + +### ๋กœ๊ทธ์ธ ํ…Œ์ŠคํŠธ +```bash +# ๊ด€๋ฆฌ์ž๋กœ ๋กœ๊ทธ์ธ +curl -X POST http://localhost:8011/api/auth/login \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=admin&password=admin123" + +# ์‘๋‹ต ์˜ˆ์‹œ: +# {"access_token":"eyJhbGci...","token_type":"bearer"} +``` + +### ํ† ํฐ ์ €์žฅ ๋ฐ ์‚ฌ์šฉ +```bash +# ํ† ํฐ์„ ๋ณ€์ˆ˜์— ์ €์žฅ +export TOKEN="eyJhbGci..." # ์œ„์—์„œ ๋ฐ›์€ ํ† ํฐ + +# ์ธ์ฆ๋œ ์š”์ฒญ - ์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ +curl -X GET http://localhost:8011/api/auth/me \ + -H "Authorization: Bearer $TOKEN" + +# ์ธ์ฆ๋œ ์š”์ฒญ - ๋ณดํ˜ธ๋œ ์—”๋“œํฌ์ธํŠธ +curl -X GET http://localhost:8011/api/protected \ + -H "Authorization: Bearer $TOKEN" + +# ์ธ์ฆ๋œ ์š”์ฒญ - Users ์„œ๋น„์Šค ์ ‘๊ทผ +curl -X GET http://localhost:8011/api/users/ \ + -H "Authorization: Bearer $TOKEN" +``` + +### ๋กœ๊ทธ์•„์›ƒ +```bash +curl -X POST http://localhost:8011/api/auth/logout \ + -H "Authorization: Bearer $TOKEN" +``` + +## 2. Postman/Insomnia์—์„œ ํ…Œ์ŠคํŠธ + +### Postman ์„ค์ • +1. **๋กœ๊ทธ์ธ ์š”์ฒญ** + - Method: POST + - URL: `http://localhost:8011/api/auth/login` + - Body: x-www-form-urlencoded + - username: admin + - password: admin123 + +2. **ํ† ํฐ ์‚ฌ์šฉ** + - Authorization ํƒญ์—์„œ Type: Bearer Token ์„ ํƒ + - Token ํ•„๋“œ์— ๋ฐ›์€ ํ† ํฐ ๋ถ™์—ฌ๋„ฃ๊ธฐ + +## 3. Python ์Šคํฌ๋ฆฝํŠธ๋กœ ํ…Œ์ŠคํŠธ + +```python +import requests + +# ๋กœ๊ทธ์ธ +login_response = requests.post( + "http://localhost:8011/api/auth/login", + data={"username": "admin", "password": "admin123"} +) +token = login_response.json()["access_token"] + +# ์ธ์ฆ๋œ ์š”์ฒญ +headers = {"Authorization": f"Bearer {token}"} +me_response = requests.get( + "http://localhost:8011/api/auth/me", + headers=headers +) +print(me_response.json()) + +# Users ์„œ๋น„์Šค ์ ‘๊ทผ +users_response = requests.get( + "http://localhost:8011/api/users/", + headers=headers +) +print(users_response.json()) +``` + +## 4. JavaScript (๋ธŒ๋ผ์šฐ์ € ์ฝ˜์†”)์—์„œ ํ…Œ์ŠคํŠธ + +```javascript +// ๋กœ๊ทธ์ธ +const loginResponse = await fetch('http://localhost:8011/api/auth/login', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: 'username=admin&password=admin123' +}); +const { access_token } = await loginResponse.json(); +console.log('Token:', access_token); + +// ์ธ์ฆ๋œ ์š”์ฒญ +const meResponse = await fetch('http://localhost:8011/api/auth/me', { + headers: {'Authorization': `Bearer ${access_token}`} +}); +const userData = await meResponse.json(); +console.log('User:', userData); +``` + +## 5. Frontend์—์„œ ํ…Œ์ŠคํŠธ (React) + +๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:3000 ์ ‘์† ํ›„ ๊ฐœ๋ฐœ์ž ๋„๊ตฌ ์ฝ˜์†”์—์„œ: + +```javascript +// ๋กœ๊ทธ์ธ ํ•จ์ˆ˜ +async function testLogin() { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: 'username=admin&password=admin123' + }); + const data = await response.json(); + localStorage.setItem('token', data.access_token); + console.log('Logged in!', data); + return data.access_token; +} + +// ์ธ์ฆ ํ…Œ์ŠคํŠธ +async function testAuth() { + const token = localStorage.getItem('token'); + const response = await fetch('/api/auth/me', { + headers: {'Authorization': `Bearer ${token}`} + }); + const data = await response.json(); + console.log('User info:', data); +} + +// ์‹คํ–‰ +await testLogin(); +await testAuth(); +``` + +## ์˜ค๋ฅ˜ ํ…Œ์ŠคํŠธ + +### ์ž˜๋ชป๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ +```bash +curl -X POST http://localhost:8011/api/auth/login \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=admin&password=wrong" +# ์‘๋‹ต: 401 Unauthorized +``` + +### ํ† ํฐ ์—†์ด ๋ณดํ˜ธ๋œ ์—”๋“œํฌ์ธํŠธ ์ ‘๊ทผ +```bash +curl -X GET http://localhost:8011/api/auth/me +# ์‘๋‹ต: 401 Unauthorized +``` + +### ์ž˜๋ชป๋œ ํ† ํฐ +```bash +curl -X GET http://localhost:8011/api/auth/me \ + -H "Authorization: Bearer invalid_token" +# ์‘๋‹ต: 401 Unauthorized +``` + +## ํ† ํฐ ์ •๋ณด + +- **์œ ํšจ ๊ธฐ๊ฐ„**: 30๋ถ„ (ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ACCESS_TOKEN_EXPIRE_MINUTES๋กœ ์„ค์ • ๊ฐ€๋Šฅ) +- **์•Œ๊ณ ๋ฆฌ์ฆ˜**: HS256 +- **ํŽ˜์ด๋กœ๋“œ**: username ์ •๋ณด ํฌํ•จ + +## ๋‹ค์Œ ๋‹จ๊ณ„ + +Frontend์— ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด UI์—์„œ ์ง์ ‘ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. \ No newline at end of file diff --git a/services/images/backend/Dockerfile b/services/images/backend/Dockerfile new file mode 100644 index 0000000..0d3642a --- /dev/null +++ b/services/images/backend/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.11-slim + +WORKDIR /app + +# ์‹œ์Šคํ…œ ํŒจํ‚ค์ง€ ์„ค์น˜ +RUN apt-get update && apt-get install -y \ + gcc \ + libheif-dev \ + libde265-dev \ + libjpeg-dev \ + libpng-dev \ + && rm -rf /var/lib/apt/lists/* + +# Python ํŒจํ‚ค์ง€ ์„ค์น˜ +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ฝ”๋“œ ๋ณต์‚ฌ +COPY . . + +# ์บ์‹œ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +RUN mkdir -p /app/cache + +EXPOSE 8000 + +CMD ["python", "main.py"] \ No newline at end of file diff --git a/services/images/backend/app/__init__.py b/services/images/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/images/backend/app/api/endpoints.py b/services/images/backend/app/api/endpoints.py new file mode 100644 index 0000000..945d3cf --- /dev/null +++ b/services/images/backend/app/api/endpoints.py @@ -0,0 +1,192 @@ +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)}" + ) \ No newline at end of file diff --git a/services/images/backend/app/core/background_tasks.py b/services/images/backend/app/core/background_tasks.py new file mode 100644 index 0000000..f5ec0f2 --- /dev/null +++ b/services/images/backend/app/core/background_tasks.py @@ -0,0 +1,91 @@ +import asyncio +import logging +from typing import Set, Optional +from pathlib import Path + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +class BackgroundTaskManager: + """๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—… ๊ด€๋ฆฌ์ž""" + + def __init__(self): + self.processing_urls: Set[str] = set() # ํ˜„์žฌ ์ฒ˜๋ฆฌ ์ค‘์ธ URL ๋ชฉ๋ก + self.task_queue: asyncio.Queue = None + self.worker_task: Optional[asyncio.Task] = None + + async def start(self): + """๋ฐฑ๊ทธ๋ผ์šด๋“œ ์›Œ์ปค ์‹œ์ž‘""" + self.task_queue = asyncio.Queue(maxsize=100) + self.worker_task = asyncio.create_task(self._worker()) + logger.info("๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—… ๊ด€๋ฆฌ์ž ์‹œ์ž‘๋จ") + + async def stop(self): + """๋ฐฑ๊ทธ๋ผ์šด๋“œ ์›Œ์ปค ์ •์ง€""" + if self.worker_task: + self.worker_task.cancel() + try: + await self.worker_task + except asyncio.CancelledError: + pass + logger.info("๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—… ๊ด€๋ฆฌ์ž ์ •์ง€๋จ") + + async def add_task(self, url: str): + """์ž‘์—… ํ์— URL ์ถ”๊ฐ€""" + if url not in self.processing_urls and self.task_queue: + try: + self.processing_urls.add(url) + await self.task_queue.put(url) + logger.info(f"๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—… ์ถ”๊ฐ€: {url}") + except asyncio.QueueFull: + self.processing_urls.discard(url) + logger.warning(f"์ž‘์—… ํ๊ฐ€ ๊ฐ€๋“ ์ฐธ: {url}") + + async def _worker(self): + """๋ฐฑ๊ทธ๋ผ์šด๋“œ ์›Œ์ปค - ํ์—์„œ ์ž‘์—…์„ ๊ฐ€์ ธ์™€ ์ฒ˜๋ฆฌ""" + from .cache import cache + + while True: + try: + # ํ์—์„œ URL ๊ฐ€์ ธ์˜ค๊ธฐ + url = await self.task_queue.get() + + try: + # ์›๋ณธ ์ด๋ฏธ์ง€๊ฐ€ ์บ์‹œ์— ์žˆ๋Š”์ง€ ํ™•์ธ + original_data = await cache.get(url, None) + + if not original_data: + # ์›๋ณธ ์ด๋ฏธ์ง€ ๋‹ค์šด๋กœ๋“œ + original_data = await cache.download_image(url) + await cache.set(url, original_data, None) + + # ๋ชจ๋“  ํฌ๊ธฐ์˜ ์ด๋ฏธ์ง€ ์ƒ์„ฑ + sizes = ['thumb', 'card', 'list', 'detail', 'hero'] + for size in sizes: + # ์ด๋ฏธ ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธ + existing = await cache.get(url, size) + if not existing: + try: + # ๋ฆฌ์‚ฌ์ด์ง• ๋ฐ ์ตœ์ ํ™” - cache.resize_and_optimize_image๊ฐ€ WebP๋ฅผ ์ฒ˜๋ฆฌํ•จ + resized_data, _ = cache.resize_and_optimize_image(original_data, size) + await cache.set(url, resized_data, size) + logger.info(f"๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ƒ์„ฑ ์™„๋ฃŒ: {url} ({size})") + except Exception as e: + logger.error(f"๋ฐฑ๊ทธ๋ผ์šด๋“œ ๋ฆฌ์‚ฌ์ด์ง• ์‹คํŒจ: {url} ({size}) - {str(e)}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + + except Exception as e: + logger.error(f"๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—… ์‹คํŒจ: {url} - {str(e)}") + finally: + # ์ฒ˜๋ฆฌ ์™„๋ฃŒ๋œ URL ์ œ๊ฑฐ + self.processing_urls.discard(url) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"๋ฐฑ๊ทธ๋ผ์šด๋“œ ์›Œ์ปค ์˜ค๋ฅ˜: {str(e)}") + await asyncio.sleep(1) # ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ์ž ์‹œ ๋Œ€๊ธฐ + +# ์ „์—ญ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—… ๊ด€๋ฆฌ์ž +background_manager = BackgroundTaskManager() \ No newline at end of file diff --git a/services/images/backend/app/core/cache.py b/services/images/backend/app/core/cache.py new file mode 100644 index 0000000..e679beb --- /dev/null +++ b/services/images/backend/app/core/cache.py @@ -0,0 +1,796 @@ +import hashlib +import aiofiles +import os +from pathlib import Path +from datetime import datetime, timedelta +from typing import Optional +import httpx +from PIL import Image +try: + from pillow_heif import register_heif_opener, register_avif_opener + register_heif_opener() # HEIF/HEIC ์ง€์› + register_avif_opener() # AVIF ์ง€์› + print("HEIF/AVIF support enabled successfully") +except ImportError: + print("Warning: pillow_heif not installed, HEIF/AVIF support disabled") +import io +import asyncio +import ssl + +from .config import settings + +class ImageCache: + def __init__(self): + self.cache_dir = settings.cache_dir + self.cache_dir.mkdir(parents=True, exist_ok=True) + + def _get_cache_path(self, url: str, size: Optional[str] = None) -> Path: + """URL์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์บ์‹œ ํŒŒ์ผ ๊ฒฝ๋กœ ์ƒ์„ฑ""" + # URL์„ ํ•ด์‹œํ•˜์—ฌ ํŒŒ์ผ๋ช… ์ƒ์„ฑ + url_hash = hashlib.md5(url.encode()).hexdigest() + + # 3๋‹จ๊ณ„ ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ ์ƒ์„ฑ + # ์˜ˆ: 10f8a8f96aa1377e86fdbc6bf3c631cf -> 10/f8/a8/ + level1 = url_hash[:2] # ์ฒซ 2์ž๋ฆฌ + level2 = url_hash[2:4] # ๋‹ค์Œ 2์ž๋ฆฌ + level3 = url_hash[4:6] # ๋‹ค์Œ 2์ž๋ฆฌ + + # ํฌ๊ธฐ๋ณ„๋กœ ๋‹ค๋ฅธ ํŒŒ์ผ๋ช… ์‚ฌ์šฉ + if size: + filename = f"{url_hash}_{size}" + else: + filename = url_hash + + # ํ™•์žฅ์ž ์ถ”์ถœ (WebP๋กœ ์ €์žฅ๋˜๋Š” ๊ฒฝ์šฐ .webp ์‚ฌ์šฉ) + if settings.convert_to_webp and size: + filename = f"{filename}.webp" + else: + ext = self._get_extension_from_url(url) + if ext: + filename = f"{filename}.{ext}" + + # 3๋‹จ๊ณ„ ๋””๋ ‰ํ† ๋ฆฌ ๊ฒฝ๋กœ ์ƒ์„ฑ + path = self.cache_dir / level1 / level2 / level3 / filename + path.parent.mkdir(parents=True, exist_ok=True) + + return path + + def _get_extension_from_url(self, url: str) -> Optional[str]: + """URL์—์„œ ํŒŒ์ผ ํ™•์žฅ์ž ์ถ”์ถœ""" + path = url.split('?')[0] # ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ ์ œ๊ฑฐ + parts = path.split('.') + if len(parts) > 1: + ext = parts[-1].lower() + if ext in settings.allowed_formats: + return ext + return None + + def _is_svg(self, data: bytes) -> bool: + """SVG ํŒŒ์ผ์ธ์ง€ ํ™•์ธ""" + # SVG ํŒŒ์ผ์˜ ์‹œ์ž‘ ๋ถ€๋ถ„ ํ™•์ธ + if len(data) < 100: + return False + + # ์ฒ˜์Œ 1000๋ฐ”์ดํŠธ๋งŒ ํ™•์ธ (์„ฑ๋Šฅ ์ตœ์ ํ™”) + header = data[:1000].lower() + + # SVG ์‹œ๊ทธ๋‹ˆ์ฒ˜ ํ™•์ธ + svg_signatures = [ + b' tuple[bytes, str]: + """GIF ์ฒ˜๋ฆฌ - JPEG๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ์•ˆ์ •์ ์œผ๋กœ ์ฒ˜๋ฆฌ""" + try: + from PIL import Image + + # GIF ์—ด๊ธฐ + img = Image.open(io.BytesIO(gif_data)) + + # ๋ชจ๋“  GIF๋ฅผ RGB๋กœ ๋ณ€ํ™˜ (ํŒ”๋ ˆํŠธ ๋ชจ๋“œ ๋ฌธ์ œ ํ•ด๊ฒฐ) + # ํŒ”๋ ˆํŠธ ๋ชจ๋“œ(P)๋ฅผ RGB๋กœ ์ง์ ‘ ๋ณ€ํ™˜ + if img.mode != 'RGB': + img = img.convert('RGB') + + # ๋ฆฌ์‚ฌ์ด์ง• + img = img.resize(target_size, Image.Resampling.LANCZOS) + + # JPEG๋กœ ์ €์žฅ (์•ˆ์ •์ ) + output = io.BytesIO() + img.save(output, format='JPEG', quality=85, optimize=True) + return output.getvalue(), 'image/jpeg' + + except Exception as e: + print(f"GIF ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜: {e}") + import traceback + traceback.print_exc() + # ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ์›๋ณธ ๋ฐ˜ํ™˜ + return gif_data, 'image/gif' + + async def get(self, url: str, size: Optional[str] = None) -> Optional[bytes]: + """์บ์‹œ์—์„œ ์ด๋ฏธ์ง€ ๊ฐ€์ ธ์˜ค๊ธฐ""" + cache_path = self._get_cache_path(url, size) + + if cache_path.exists(): + # ์บ์‹œ ๋งŒ๋ฃŒ ํ™•์ธ + stat = cache_path.stat() + age = datetime.now() - datetime.fromtimestamp(stat.st_mtime) + + if age < timedelta(days=settings.cache_ttl_days): + async with aiofiles.open(cache_path, 'rb') as f: + return await f.read() + else: + # ๋งŒ๋ฃŒ๋œ ์บ์‹œ ์‚ญ์ œ + cache_path.unlink() + + return None + + async def set(self, url: str, data: bytes, size: Optional[str] = None): + """์บ์‹œ์— ์ด๋ฏธ์ง€ ์ €์žฅ""" + cache_path = self._get_cache_path(url, size) + + async with aiofiles.open(cache_path, 'wb') as f: + await f.write(data) + + async def download_image(self, url: str) -> bytes: + """์™ธ๋ถ€ URL์—์„œ ์ด๋ฏธ์ง€ ๋‹ค์šด๋กœ๋“œ""" + from urllib.parse import urlparse + + # URL์—์„œ ๋„๋ฉ”์ธ ์ถ”์ถœ + parsed_url = urlparse(url) + domain = parsed_url.netloc + base_url = f"{parsed_url.scheme}://{parsed_url.netloc}/" + + # ๊ธฐ๋ณธ ํ—ค๋” ์„ค์ • + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + 'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7', + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache', + 'Sec-Fetch-Dest': 'image', + 'Sec-Fetch-Mode': 'no-cors', + 'Sec-Fetch-Site': 'cross-site', + 'Referer': base_url # ํ•ญ์ƒ ๊ธฐ๋ณธ Referer ์„ค์ • + } + + # ํŠน์ • ์‚ฌ์ดํŠธ๋ณ„ Referer ์˜ค๋ฒ„๋ผ์ด๋“œ + if 'yna.co.kr' in url: + headers['Referer'] = 'https://www.yna.co.kr/' + client = httpx.AsyncClient( + verify=False, # SSL ๊ฒ€์ฆ ๋น„ํ™œ์„ฑํ™” + timeout=30.0, + follow_redirects=True + ) + elif 'investing.com' in url: + headers['Referer'] = 'https://www.investing.com/' + client = httpx.AsyncClient() + elif 'naver.com' in url: + headers['Referer'] = 'https://news.naver.com/' + client = httpx.AsyncClient() + elif 'daum.net' in url: + headers['Referer'] = 'https://news.daum.net/' + client = httpx.AsyncClient() + elif 'chosun.com' in url: + headers['Referer'] = 'https://www.chosun.com/' + client = httpx.AsyncClient() + elif 'vietnam.vn' in url or 'vstatic.vietnam.vn' in url: + headers['Referer'] = 'https://vietnam.vn/' + client = httpx.AsyncClient() + elif 'ddaily.co.kr' in url: + # ddaily๋Š” /photos/ ๊ฒฝ๋กœ๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ•จ + headers['Referer'] = 'https://www.ddaily.co.kr/' + # URL์ด ์ž˜๋ชป๋œ ๊ฒฝ๋กœ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ ์ˆ˜์ • + if '/2025/' in url and '/photos/' not in url: + url = url.replace('/2025/', '/photos/2025/') + print(f"Fixed ddaily URL: {url}") + client = httpx.AsyncClient() + else: + # ๊ธฐ๋ณธ์ ์œผ๋กœ ๋„๋ฉ”์ธ ๊ธฐ๋ฐ˜ Referer ์‚ฌ์šฉ + client = httpx.AsyncClient() + + async with client: + try: + response = await client.get( + url, + headers=headers, + timeout=settings.request_timeout, + follow_redirects=True + ) + response.raise_for_status() + except Exception as e: + # ๋ชจ๋“  ์—๋Ÿฌ์— ๋Œ€ํ•ด Playwright ์‚ฌ์šฉ ์‹œ๋„ + error_msg = str(e) + if isinstance(e, httpx.HTTPStatusError): + error_type = f"HTTP {e.response.status_code}" + elif isinstance(e, httpx.ConnectError): + error_type = "Connection Error" + elif isinstance(e, ssl.SSLError): + error_type = "SSL Error" + elif "resolve" in error_msg.lower() or "dns" in error_msg.lower(): + error_type = "DNS Resolution Error" + else: + error_type = "Network Error" + + print(f"{error_type} for {url}, trying with Playwright...") + + # Playwright๋กœ ์ด๋ฏธ์ง€ ๊ฐ€์ ธ์˜ค๊ธฐ ์‹œ๋„ + try: + from playwright.async_api import async_playwright + from PIL import Image + import io + + async with async_playwright() as p: + # ๋ธŒ๋ผ์šฐ์ € ์‹คํ–‰ + browser = await p.chromium.launch( + headless=True, + args=['--no-sandbox', '--disable-setuid-sandbox'] + ) + + # Referer ์„ค์ •์„ ์œ„ํ•œ ๋„๋ฉ”์ธ ์ถ”์ถœ + from urllib.parse import urlparse + parsed = urlparse(url) + referer_url = f"{parsed.scheme}://{parsed.netloc}/" + + context = await browser.new_context( + viewport={'width': 1920, 'height': 1080}, + user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + extra_http_headers={ + 'Referer': referer_url + } + ) + + page = await context.new_page() + + try: + # Response๋ฅผ ๊ฐ€๋กœ์ฑ„๊ธฐ ์œ„ํ•œ ์„ค์ • + image_data = None + + async def handle_response(response): + nonlocal image_data + # ์ด๋ฏธ์ง€ URL์— ๋Œ€ํ•œ ์‘๋‹ต ๊ฐ€๋กœ์ฑ„๊ธฐ + if url in response.url or response.url == url: + try: + image_data = await response.body() + print(f"โœ… Image intercepted: {len(image_data)} bytes") + except: + pass + + # Response ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ ๋“ฑ๋ก + page.on('response', handle_response) + + # ์ด๋ฏธ์ง€ URL๋กœ ์ด๋™ (์—๋Ÿฌ ๋ฌด์‹œ) + try: + await page.goto(url, wait_until='networkidle', timeout=30000) + except Exception as goto_error: + print(f"โš ๏ธ Direct navigation failed: {goto_error}") + # ์ง์ ‘ ์ด๋™ ์‹คํŒจ ์‹œ HTML์— img ํƒœ๊ทธ ์‚ฝ์ž… + await page.set_content(f''' + + + + + + ''') + await page.wait_for_timeout(3000) # ์ด๋ฏธ์ง€ ๋กœ๋”ฉ ๋Œ€๊ธฐ + + # ์ด๋ฏธ์ง€ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด JavaScript๋กœ ์ง์ ‘ fetch + if not image_data: + # JavaScript๋กœ ์ด๋ฏธ์ง€ fetch + image_data_base64 = await page.evaluate(''' + async (url) => { + try { + const response = await fetch(url); + const blob = await response.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result.split(',')[1]); + reader.readAsDataURL(blob); + }); + } catch (e) { + return null; + } + } + ''', url) + + if image_data_base64: + import base64 + image_data = base64.b64decode(image_data_base64) + print(f"โœ… Image fetched via JavaScript: {len(image_data)} bytes") + + # ์—ฌ์ „ํžˆ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ์Šคํฌ๋ฆฐ์ƒท ์‚ฌ์šฉ + if not image_data: + # ์ด๋ฏธ์ง€ ์š”์†Œ ์ฐพ๊ธฐ + img_element = await page.query_selector('img') + if img_element: + # ์ด๋ฏธ์ง€๊ฐ€ ๋กœ๋“œ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ + is_loaded = await img_element.evaluate('(img) => img.complete && img.naturalHeight > 0') + if is_loaded: + image_data = await img_element.screenshot() + print(f"โœ… Screenshot from loaded image: {len(image_data)} bytes") + else: + # ์ด๋ฏธ์ง€ ๋กœ๋“œ ๋Œ€๊ธฐ + try: + await img_element.evaluate('(img) => new Promise(r => img.onload = r)') + image_data = await img_element.screenshot() + print(f"โœ… Screenshot after waiting: {len(image_data)} bytes") + except: + # ์ „์ฒด ํŽ˜์ด์ง€ ์Šคํฌ๋ฆฐ์ƒท + image_data = await page.screenshot(full_page=True) + print(f"โš ๏ธ Full page screenshot: {len(image_data)} bytes") + else: + image_data = await page.screenshot(full_page=True) + print(f"โš ๏ธ No image element, full screenshot: {len(image_data)} bytes") + + print(f"โœ… Successfully fetched image with Playwright: {url}") + return image_data + + finally: + await page.close() + await context.close() + await browser.close() + + except Exception as pw_error: + print(f"Playwright failed: {pw_error}, returning placeholder") + + # Playwright๋„ ์‹คํŒจํ•˜๋ฉด ์„ธ๋ จ๋œ placeholder ๋ฐ˜ํ™˜ + from PIL import Image, ImageDraw, ImageFont + import io + import random + + # ๊ทธ๋ผ๋””์–ธํŠธ ๋ฐฐ๊ฒฝ์ƒ‰ ์„ ํƒ (๋ถ€๋“œ๋Ÿฌ์šด ์ƒ‰์ƒ) + gradients = [ + ('#667eea', '#764ba2'), # ๋ณด๋ผ ๊ทธ๋ผ๋””์–ธํŠธ + ('#f093fb', '#f5576c'), # ํ•‘ํฌ ๊ทธ๋ผ๋””์–ธํŠธ + ('#4facfe', '#00f2fe'), # ํ•˜๋Š˜์ƒ‰ ๊ทธ๋ผ๋””์–ธํŠธ + ('#43e97b', '#38f9d7'), # ๋ฏผํŠธ ๊ทธ๋ผ๋””์–ธํŠธ + ('#fa709a', '#fee140'), # ์„ ์…‹ ๊ทธ๋ผ๋””์–ธํŠธ + ('#30cfd0', '#330867'), # ๋”ฅ ์˜ค์…˜ + ('#a8edea', '#fed6e3'), # ํŒŒ์Šคํ…” + ('#ffecd2', '#fcb69f'), # ํ”ผ์น˜ + ] + + # ๋žœ๋ค ๊ทธ๋ผ๋””์–ธํŠธ ์„ ํƒ + color1, color2 = random.choice(gradients) + + # ์ด๋ฏธ์ง€ ์ƒ์„ฑ (16:9 ๋น„์œจ) + width, height = 800, 450 + img = Image.new('RGB', (width, height)) + draw = ImageDraw.Draw(img) + + # ๊ทธ๋ผ๋””์–ธํŠธ ๋ฐฐ๊ฒฝ ์ƒ์„ฑ + def hex_to_rgb(hex_color): + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + + rgb1 = hex_to_rgb(color1) + rgb2 = hex_to_rgb(color2) + + # ์„ธ๋กœ ๊ทธ๋ผ๋””์–ธํŠธ + for y in range(height): + ratio = y / height + r = int(rgb1[0] * (1 - ratio) + rgb2[0] * ratio) + g = int(rgb1[1] * (1 - ratio) + rgb2[1] * ratio) + b = int(rgb1[2] * (1 - ratio) + rgb2[2] * ratio) + draw.rectangle([(0, y), (width, y + 1)], fill=(r, g, b)) + + # ๋ฐ˜ํˆฌ๋ช… ์˜ค๋ฒ„๋ ˆ์ด ์ถ”๊ฐ€ (๊นŠ์ด๊ฐ) + overlay = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + overlay_draw = ImageDraw.Draw(overlay) + + # ์ค‘์•™ ์›ํ˜• ๊ทธ๋ผ๋””์–ธํŠธ ํšจ๊ณผ + center_x, center_y = width // 2, height // 2 + max_radius = min(width, height) // 3 + + for radius in range(max_radius, 0, -2): + opacity = int(255 * (1 - radius / max_radius) * 0.3) + overlay_draw.ellipse( + [(center_x - radius, center_y - radius), + (center_x + radius, center_y + radius)], + fill=(255, 255, 255, opacity) + ) + + # ์ด๋ฏธ์ง€ ์•„์ด์ฝ˜ ๊ทธ๋ฆฌ๊ธฐ (์‚ฐ ๋ชจ์–‘) + icon_color = (255, 255, 255, 200) + icon_size = 80 + icon_x = center_x + icon_y = center_y - 20 + + # ์‚ฐ ์•„์ด์ฝ˜ (์‚ฌ์ง„ ์ด๋ฏธ์ง€๋ฅผ ๋‚˜ํƒ€๋ƒ„) + mountain_points = [ + (icon_x - icon_size, icon_y + icon_size//2), + (icon_x - icon_size//2, icon_y - icon_size//4), + (icon_x - icon_size//4, icon_y), + (icon_x + icon_size//4, icon_y - icon_size//2), + (icon_x + icon_size, icon_y + icon_size//2), + ] + overlay_draw.polygon(mountain_points, fill=icon_color) + + # ํƒœ์–‘/๋‹ฌ ์› + sun_radius = icon_size // 4 + overlay_draw.ellipse( + [(icon_x - icon_size//2, icon_y - icon_size//2 - sun_radius), + (icon_x - icon_size//2 + sun_radius*2, icon_y - icon_size//2 + sun_radius)], + fill=icon_color + ) + + # ํ”„๋ ˆ์ž„ ํ…Œ๋‘๋ฆฌ + frame_margin = 40 + overlay_draw.rectangle( + [(frame_margin, frame_margin), + (width - frame_margin, height - frame_margin)], + outline=(255, 255, 255, 150), + width=3 + ) + + # ์ฝ”๋„ˆ ์žฅ์‹ + corner_size = 20 + corner_width = 4 + corners = [ + (frame_margin, frame_margin), + (width - frame_margin - corner_size, frame_margin), + (frame_margin, height - frame_margin - corner_size), + (width - frame_margin - corner_size, height - frame_margin - corner_size) + ] + + for x, y in corners: + # ๊ฐ€๋กœ์„  + overlay_draw.rectangle( + [(x, y), (x + corner_size, y + corner_width)], + fill=(255, 255, 255, 200) + ) + # ์„ธ๋กœ์„  + overlay_draw.rectangle( + [(x, y), (x + corner_width, y + corner_size)], + fill=(255, 255, 255, 200) + ) + + # "Image Loading..." ํ…์ŠคํŠธ (์ž‘๊ฒŒ) + try: + # ์‹œ์Šคํ…œ ํฐํŠธ ์‹œ๋„ + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16) + except: + font = ImageFont.load_default() + + text = "Image Loading..." + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + text_x = (width - text_width) // 2 + text_y = center_y + icon_size + + # ํ…์ŠคํŠธ ๊ทธ๋ฆผ์ž + for offset in [(2, 2), (-1, -1)]: + overlay_draw.text( + (text_x + offset[0], text_y + offset[1]), + text, + font=font, + fill=(0, 0, 0, 100) + ) + + # ํ…์ŠคํŠธ ๋ณธ์ฒด + overlay_draw.text( + (text_x, text_y), + text, + font=font, + fill=(255, 255, 255, 220) + ) + + # ์˜ค๋ฒ„๋ ˆ์ด ํ•ฉ์„ฑ + img = Image.alpha_composite(img.convert('RGBA'), overlay).convert('RGB') + + # ์•ฝ๊ฐ„์˜ ๋…ธ์ด์ฆˆ ์ถ”๊ฐ€ (ํ…์Šค์ฒ˜) + pixels = img.load() + for _ in range(1000): + x = random.randint(0, width - 1) + y = random.randint(0, height - 1) + r, g, b = pixels[x, y] + brightness = random.randint(-20, 20) + pixels[x, y] = ( + max(0, min(255, r + brightness)), + max(0, min(255, g + brightness)), + max(0, min(255, b + brightness)) + ) + + # JPEG๋กœ ๋ณ€ํ™˜ (๋†’์€ ํ’ˆ์งˆ) + output = io.BytesIO() + img.save(output, format='JPEG', quality=85, optimize=True) + return output.getvalue() + raise + + # ์ด๋ฏธ์ง€ ํฌ๊ธฐ ํ™•์ธ + content_length = int(response.headers.get('content-length', 0)) + max_size = settings.max_image_size_mb * 1024 * 1024 + + if content_length > max_size: + raise ValueError(f"Image too large: {content_length} bytes") + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ํ™•์ธ + content = response.content + print(f"Downloaded {len(content)} bytes from {url[:50]}...") + + # gzip ์••์ถ• ํ™•์ธ ๋ฐ ํ•ด์ œ + import gzip + if len(content) > 2 and content[:2] == b'\x1f\x8b': + print("๐Ÿ“ฆ Gzip compressed data detected, decompressing...") + try: + content = gzip.decompress(content) + print(f"โœ… Decompressed to {len(content)} bytes") + except Exception as e: + print(f"โŒ Failed to decompress gzip: {e}") + + # ์ฒ˜์Œ ๋ช‡ ๋ฐ”์ดํŠธ๋กœ ์ด๋ฏธ์ง€ ํ˜•์‹ ํ™•์ธ + if len(content) > 10: + header = content[:12] + if header[:2] == b'\xff\xd8': + print("โœ… JPEG image detected") + elif header[:8] == b'\x89PNG\r\n\x1a\n': + print("โœ… PNG image detected") + elif header[:6] in (b'GIF87a', b'GIF89a'): + print("โœ… GIF image detected") + elif header[:4] == b'RIFF' and header[8:12] == b'WEBP': + print("โœ… WebP image detected") + elif b' tuple[bytes, str]: + """์ด๋ฏธ์ง€ ๋ฆฌ์‚ฌ์ด์ง• ๋ฐ ์ตœ์ ํ™”""" + if size not in settings.thumbnail_sizes: + raise ValueError(f"Invalid size: {size}") + + target_size = settings.thumbnail_sizes[size] + + # SVG ์ฒดํฌ - SVG๋Š” ๋ฆฌ์‚ฌ์ด์ง•ํ•˜์ง€ ์•Š๊ณ  ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜ + if self._is_svg(image_data): + return image_data, 'image/svg+xml' + + # PIL๋กœ ์ด๋ฏธ์ง€ ์—ด๊ธฐ + try: + img = Image.open(io.BytesIO(image_data)) + except Exception as e: + # WebP ํ—ค๋” ์ฒดํฌ (RIFF....WEBP) + header = image_data[:12] if len(image_data) >= 12 else image_data + if header[:4] == b'RIFF' and header[8:12] == b'WEBP': + print("๐ŸŽจ WebP ์ด๋ฏธ์ง€ ๊ฐ์ง€๋จ, ๋ณ€ํ™˜ ์‹œ๋„") + # WebP ํ˜•์‹์ด์ง€๋งŒ PIL์ด ์—ด์ง€ ๋ชปํ•˜๋Š” ๊ฒฝ์šฐ + # Pillow-SIMD ๋˜๋Š” ์ถ”๊ฐ€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ํ•„์š”ํ•  ์ˆ˜ ์žˆ์Œ + try: + # ์žฌ์‹œ๋„ + from PIL import WebPImagePlugin + img = Image.open(io.BytesIO(image_data)) + except: + print("โŒ WebP ์ด๋ฏธ์ง€๋ฅผ ์—ด ์ˆ˜ ์—†์Œ, ์›๋ณธ ๋ฐ˜ํ™˜") + return image_data, 'image/webp' + else: + raise e + + # GIF ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ฒดํฌ ๋ฐ ์ฒ˜๋ฆฌ + if getattr(img, "format", None) == "GIF": + return self._process_gif(image_data, target_size) + + # WebP ํ˜•์‹ ์ฒดํฌ + original_format = getattr(img, "format", None) + is_webp = original_format == "WEBP" + + # ์›๋ณธ ๋ชจ๋“œ์™€ ํˆฌ๋ช…๋„ ์ •๋ณด ์ €์žฅ + original_mode = img.mode + original_has_transparency = img.mode in ('RGBA', 'LA') + original_has_palette = img.mode == 'P' + + # ํŒ”๋ ˆํŠธ ๋ชจ๋“œ(P) ์ฒ˜๋ฆฌ - ๊ฐ„๋‹จํ•˜๊ฒŒ PIL์˜ ๊ธฐ๋ณธ ๋ณ€ํ™˜ ์‚ฌ์šฉ + if img.mode == 'P': + # ํŒ”๋ ˆํŠธ ๋ชจ๋“œ๋Š” RGB๋กœ ์ง์ ‘ ๋ณ€ํ™˜ + # PIL์˜ convert ๋ฉ”์„œ๋“œ๊ฐ€ ํŒ”๋ ˆํŠธ๋ฅผ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ฒ˜๋ฆฌํ•จ + img = img.convert('RGB') + + # ํˆฌ๋ช…๋„๊ฐ€ ์žˆ๋Š” ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ + if img.mode == 'RGBA': + # RGBA๋Š” ํฐ์ƒ‰ ๋ฐฐ๊ฒฝ๊ณผ ํ•ฉ์„ฑ + background = Image.new('RGB', img.size, (255, 255, 255)) + background.paste(img, mask=img.split()[-1]) + img = background + elif img.mode == 'LA': + # LA(๊ทธ๋ ˆ์ด์Šค์ผ€์ผ+์•ŒํŒŒ)๋Š” RGBA๋ฅผ ๊ฑฐ์ณ RGB๋กœ + img = img.convert('RGBA') + background = Image.new('RGB', img.size, (255, 255, 255)) + background.paste(img, mask=img.split()[-1]) + img = background + elif img.mode == 'L': + # ๊ทธ๋ ˆ์ด์Šค์ผ€์ผ์€ RGB๋กœ ๋ณ€ํ™˜ + img = img.convert('RGB') + elif img.mode not in ('RGB',): + # ๊ธฐํƒ€ ๋ชจ๋“œ๋Š” ๋ชจ๋‘ RGB๋กœ ๋ณ€ํ™˜ + img = img.convert('RGB') + + # EXIF ๋ฐฉํ–ฅ ์ •๋ณด ์ฒ˜๋ฆฌ (RGB ๋ณ€ํ™˜ ํ›„์— ์ˆ˜ํ–‰) + try: + from PIL import ImageOps + img = ImageOps.exif_transpose(img) + except: + pass + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ œ๊ฑฐ๋Š” ์Šคํ‚ต (ํŒ”๋ ˆํŠธ ๋ชจ๋“œ ์ด๋ฏธ์ง€์—์„œ ๋ฌธ์ œ ๋ฐœ์ƒ) + # RGB๋กœ ๋ณ€ํ™˜๋˜์—ˆ์œผ๋ฏ€๋กœ ์ด๋ฏธ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋Š” ๋Œ€๋ถ€๋ถ„ ์ œ๊ฑฐ๋จ + + # ๋น„์œจ ์œ ์ง€ํ•˜๋ฉฐ ๋ฆฌ์‚ฌ์ด์ง• (ํฌ๋กญ ์—†์ด) + img_ratio = img.width / img.height + target_width = target_size[0] + target_height = target_size[1] + + # ์›๋ณธ ๋น„์œจ์„ ์œ ์ง€ํ•˜๋ฉด์„œ ๋ชฉํ‘œ ํฌ๊ธฐ์— ๋งž์ถ”๊ธฐ + # ๋„ˆ๋น„ ๋˜๋Š” ๋†’์ด ์ค‘ ํ•˜๋‚˜๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๋น„์œจ ๊ณ„์‚ฐ + if img.width > target_width or img.height > target_height: + # ๋„ˆ๋น„ ๊ธฐ์ค€ ๋ฆฌ์‚ฌ์ด์ง• + width_ratio = target_width / img.width + # ๋†’์ด ๊ธฐ์ค€ ๋ฆฌ์‚ฌ์ด์ง• + height_ratio = target_height / img.height + # ๋‘˜ ์ค‘ ์ž‘์€ ๋น„์œจ ์‚ฌ์šฉ (๋ชฉํ‘œ ํฌ๊ธฐ๋ฅผ ๋„˜์ง€ ์•Š๋„๋ก) + ratio = min(width_ratio, height_ratio) + + new_width = int(img.width * ratio) + new_height = int(img.height * ratio) + + # ํฐ ์ด๋ฏธ์ง€๋ฅผ ์ž‘๊ฒŒ ๋งŒ๋“ค ๋•Œ๋Š” 2๋‹จ๊ณ„ ๋ฆฌ์ƒ˜ํ”Œ๋ง์œผ๋กœ ํ’ˆ์งˆ ํ–ฅ์ƒ + if img.width > new_width * 2 or img.height > new_height * 2: + # 1๋‹จ๊ณ„: ๋ชฉํ‘œ ํฌ๊ธฐ์˜ 2๋ฐฐ๋กœ ๋จผ์ € ์ถ•์†Œ + intermediate_width = new_width * 2 + intermediate_height = new_height * 2 + img = img.resize((intermediate_width, intermediate_height), Image.Resampling.LANCZOS) + + # ์ตœ์ข… ๋ชฉํ‘œ ํฌ๊ธฐ๋กœ ๋ฆฌ์ƒ˜ํ”Œ๋ง + img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # ์ƒคํ”„๋‹ ์ ์šฉ (์ž‘์€ ์ด๋ฏธ์ง€์—๋งŒ) + if target_size[0] <= 400: + from PIL import ImageEnhance + enhancer = ImageEnhance.Sharpness(img) + img = enhancer.enhance(1.2) + + # ๋ฐ”์ดํŠธ๋กœ ๋ณ€ํ™˜ + output = io.BytesIO() + + # ์ ์‘ํ˜• ํ’ˆ์งˆ ๊ณ„์‚ฐ (์ด๋ฏธ์ง€ ํฌ๊ธฐ์— ๋”ฐ๋ผ ์กฐ์ •) + def get_adaptive_quality(base_quality: int, target_width: int) -> int: + """์ด๋ฏธ์ง€ ํฌ๊ธฐ์— ๋”ฐ๋ฅธ ์ ์‘ํ˜• ํ’ˆ์งˆ ๊ณ„์‚ฐ""" + # ํ’ˆ์งˆ์„ ๋” ๋†’๊ฒŒ ์„ค์ •ํ•˜์—ฌ ๊ฒ€์ •์ƒ‰ ๋ฌธ์ œ ํ•ด๊ฒฐ + if target_width <= 150: # ์ธ๋„ค์ผ + return min(base_quality + 10, 95) + elif target_width <= 360: # ์นด๋“œ + return min(base_quality + 5, 90) + elif target_width <= 800: # ์ƒ์„ธ + return base_quality # 85 + else: # ํžˆ์–ด๋กœ + return base_quality # 85 + + # WebP ๋ณ€ํ™˜ ๋ฐ ์ตœ์ ํ™” - ์ตœ๊ณ  ์••์ถ•๋ฅ  ์„ค์ • + # WebP ์ž…๋ ฅ์€ JPEG๋กœ ๋ณ€ํ™˜ (WebP ๋ฆฌ์‚ฌ์ด์ง• ๋ฌธ์ œ ํšŒํ”ผ) + if is_webp: + output_format = 'JPEG' + content_type = 'image/jpeg' + else: + output_format = 'WEBP' if settings.convert_to_webp else 'JPEG' + content_type = 'image/webp' if output_format == 'WEBP' else 'image/jpeg' + + if output_format == 'WEBP': + # WebP ์ตœ์ ํ™”: method=6(์ตœ๊ณ ํ’ˆ์งˆ), lossless=False, exact=False + adaptive_quality = get_adaptive_quality(settings.webp_quality, target_size[0]) + + save_kwargs = { + 'format': 'WEBP', + 'quality': adaptive_quality, + 'method': 6, # ์ตœ๊ณ  ์••์ถ• ์•Œ๊ณ ๋ฆฌ์ฆ˜ (0-6) + 'lossless': settings.webp_lossless, + 'exact': False, # ์•ฝ๊ฐ„์˜ ํ’ˆ์งˆ ์†์‹ค ํ—ˆ์šฉํ•˜์—ฌ ๋” ์ž‘์€ ํฌ๊ธฐ + } + + img.save(output, **save_kwargs) + elif original_has_transparency and not settings.convert_to_webp: + # PNG ์ตœ์ ํ™” (ํˆฌ๋ช…๋„๊ฐ€ ์žˆ๋Š” ์ด๋ฏธ์ง€) + save_kwargs = { + 'format': 'PNG', + 'optimize': settings.optimize_png, + 'compress_level': settings.png_compress_level, + } + + # ํŒ”๋ ˆํŠธ ๋ชจ๋“œ๋กœ ๋ณ€ํ™˜ ๊ฐ€๋Šฅํ•œ์ง€ ํ™•์ธ (256์ƒ‰ ์ดํ•˜) + if settings.optimize_png: + try: + # ์ƒ‰์ƒ ์ˆ˜๊ฐ€ 256๊ฐœ ์ดํ•˜์ด๋ฉด ํŒ”๋ ˆํŠธ ๋ชจ๋“œ๋กœ ๋ณ€ํ™˜ + quantized = img.quantize(colors=256, method=Image.Quantize.MEDIANCUT) + if len(quantized.getcolors()) <= 256: + img = quantized + save_kwargs['format'] = 'PNG' + except: + pass + + content_type = 'image/png' + img.save(output, **save_kwargs) + else: + # JPEG ์ตœ์ ํ™” ์„ค์ • (๊ธฐ๋ณธ๊ฐ’) + adaptive_quality = get_adaptive_quality(settings.jpeg_quality, target_size[0]) + + save_kwargs = { + 'format': 'JPEG', + 'quality': adaptive_quality, + 'optimize': True, + 'progressive': settings.progressive_jpeg, + } + + img.save(output, **save_kwargs) + + return output.getvalue(), content_type + + async def get_cache_size(self) -> float: + """ํ˜„์žฌ ์บ์‹œ ํฌ๊ธฐ (GB)""" + total_size = 0 + + for dirpath, dirnames, filenames in os.walk(self.cache_dir): + for filename in filenames: + filepath = os.path.join(dirpath, filename) + total_size += os.path.getsize(filepath) + + return total_size / (1024 ** 3) # GB๋กœ ๋ณ€ํ™˜ + + async def cleanup_old_cache(self): + """์˜ค๋ž˜๋œ ์บ์‹œ ํŒŒ์ผ ์ •๋ฆฌ""" + cutoff_time = datetime.now() - timedelta(days=settings.cache_ttl_days) + + for dirpath, dirnames, filenames in os.walk(self.cache_dir): + for filename in filenames: + filepath = Path(dirpath) / filename + + if filepath.stat().st_mtime < cutoff_time.timestamp(): + filepath.unlink() + + async def trigger_background_generation(self, url: str): + """๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ๋ชจ๋“  ํฌ๊ธฐ์˜ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ํŠธ๋ฆฌ๊ฑฐ""" + from .background_tasks import background_manager + + # ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—… ํ์— ์ถ”๊ฐ€ + asyncio.create_task(background_manager.add_task(url)) + + async def get_directory_stats(self) -> dict: + """๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ ํ†ต๊ณ„ ์ •๋ณด""" + total_files = 0 + total_dirs = 0 + files_per_dir = {} + + for root, dirs, files in os.walk(self.cache_dir): + total_dirs += len(dirs) + total_files += len(files) + + # ๊ฐ ๋””๋ ‰ํ† ๋ฆฌ์˜ ํŒŒ์ผ ์ˆ˜ ๊ณ„์‚ฐ + rel_path = os.path.relpath(root, self.cache_dir) + depth = len(Path(rel_path).parts) if rel_path != '.' else 0 + + if files and depth == 3: # 3๋‹จ๊ณ„ ๋””๋ ‰ํ† ๋ฆฌ์—์„œ๋งŒ ํŒŒ์ผ ์ˆ˜ ๊ณ„์‚ฐ + files_per_dir[rel_path] = len(files) + + # ํ†ต๊ณ„ ๊ณ„์‚ฐ + avg_files_per_dir = sum(files_per_dir.values()) / len(files_per_dir) if files_per_dir else 0 + max_files_in_dir = max(files_per_dir.values()) if files_per_dir else 0 + + return { + "total_files": total_files, + "total_directories": total_dirs, + "average_files_per_directory": round(avg_files_per_dir, 2), + "max_files_in_single_directory": max_files_in_dir, + "directory_depth": 3 + } + +cache = ImageCache() \ No newline at end of file diff --git a/services/images/backend/app/core/config.py b/services/images/backend/app/core/config.py new file mode 100644 index 0000000..418b1eb --- /dev/null +++ b/services/images/backend/app/core/config.py @@ -0,0 +1,46 @@ +from pydantic_settings import BaseSettings +from pathlib import Path + +class Settings(BaseSettings): + # ๊ธฐ๋ณธ ์„ค์ • + app_name: str = "Image Proxy Service" + debug: bool = True + + # ์บ์‹œ ์„ค์ • + cache_dir: Path = Path("/app/cache") + max_cache_size_gb: int = 10 + cache_ttl_days: int = 30 + + # ์ด๋ฏธ์ง€ ์„ค์ • + max_image_size_mb: int = 20 + allowed_formats: list = ["jpg", "jpeg", "png", "gif", "webp", "svg"] + + # ๋ฆฌ์‚ฌ์ด์ง• ์„ค์ • - ๋‰ด์Šค ์นด๋“œ ์šฉ๋„๋ณ„ ์ตœ์ ํ™” + thumbnail_sizes: dict = { + "thumb": (150, 100), # ์ž‘์€ ์ธ๋„ค์ผ (3:2 ๋น„์œจ) + "card": (360, 240), # ๋‰ด์Šค ์นด๋“œ์šฉ (3:2 ๋น„์œจ) + "list": (300, 200), # ๋ฆฌ์ŠคํŠธ์šฉ (3:2 ๋น„์œจ) + "detail": (800, 533), # ์ƒ์„ธ ํŽ˜์ด์ง€์šฉ (์›๋ณธ ๋น„์œจ ์œ ์ง€) + "hero": (1200, 800) # ํžˆ์–ด๋กœ ์ด๋ฏธ์ง€์šฉ (์›๋ณธ ๋น„์œจ ์œ ์ง€) + } + + # ์ด๋ฏธ์ง€ ์ตœ์ ํ™” ์„ค์ • - ํ’ˆ์งˆ ๋ณด์žฅํ•˜๋ฉด์„œ ์ตœ์ € ์šฉ๋Ÿ‰ + jpeg_quality: int = 85 # JPEG ํ’ˆ์งˆ (ํ’ˆ์งˆ ํ–ฅ์ƒ) + webp_quality: int = 85 # WebP ํ’ˆ์งˆ (ํ’ˆ์งˆ ํ–ฅ์ƒ์œผ๋กœ ๊ฒ€์ •์ƒ‰ ๋ฌธ์ œ ํ•ด๊ฒฐ) + webp_lossless: bool = False # ๋ฌด์†์‹ค ์••์ถ• ๋น„ํ™œ์„ฑํ™” (์šฉ๋Ÿ‰ ์ตœ์ ํ™”) + png_compress_level: int = 9 # PNG ์ตœ๋Œ€ ์••์ถ• (0-9, 9๊ฐ€ ์ตœ๊ณ  ์••์ถ•) + convert_to_webp: bool = False # WebP ๋ณ€ํ™˜ ์ž„์‹œ ๋น„ํ™œ์„ฑํ™” (๊ฒ€์ •์ƒ‰ ์ด๋ฏธ์ง€ ๋ฌธ์ œ) + + # ๊ณ ๊ธ‰ ์ตœ์ ํ™” ์„ค์ • + progressive_jpeg: bool = True # ์ ์ง„์  JPEG (๋กœ๋”ฉ ์„ฑ๋Šฅ ํ–ฅ์ƒ) + strip_metadata: bool = True # EXIF ๋“ฑ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ œ๊ฑฐ (์šฉ๋Ÿ‰ ์ ˆ์•ฝ) + optimize_png: bool = True # PNG ํŒ”๋ ˆํŠธ ์ตœ์ ํ™” + + # ์™ธ๋ถ€ ์š”์ฒญ ์„ค์ • + request_timeout: int = 30 + user_agent: str = "ImageProxyService/1.0" + + class Config: + env_file = ".env" + +settings = Settings() \ No newline at end of file diff --git a/services/images/backend/main.py b/services/images/backend/main.py new file mode 100644 index 0000000..88d875c --- /dev/null +++ b/services/images/backend/main.py @@ -0,0 +1,65 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager +import uvicorn +from datetime import datetime + +from app.api.endpoints import router +from app.core.config import settings + +@asynccontextmanager +async def lifespan(app: FastAPI): + # ์‹œ์ž‘ ์‹œ + print("Images service starting...") + yield + # ์ข…๋ฃŒ ์‹œ + print("Images service stopping...") + +app = FastAPI( + title="Images Service", + description="์ด๋ฏธ์ง€ ์—…๋กœ๋“œ, ํ”„๋ก์‹œ ๋ฐ ์บ์‹ฑ ์„œ๋น„์Šค", + version="2.0.0", + lifespan=lifespan +) + +# CORS ์„ค์ • +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ๋ผ์šฐํ„ฐ ๋“ฑ๋ก +app.include_router(router, prefix="/api/v1") + +@app.get("/") +async def root(): + return { + "service": "Images Service", + "version": "2.0.0", + "timestamp": datetime.now().isoformat(), + "endpoints": { + "proxy": "/api/v1/image?url=&size=", + "upload": "/api/v1/upload", + "stats": "/api/v1/stats", + "cleanup": "/api/v1/cleanup" + } + } + +@app.get("/health") +async def health_check(): + return { + "status": "healthy", + "service": "images", + "timestamp": datetime.now().isoformat() + } + +if __name__ == "__main__": + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=True + ) \ No newline at end of file diff --git a/services/images/backend/requirements.txt b/services/images/backend/requirements.txt new file mode 100644 index 0000000..98c0b9b --- /dev/null +++ b/services/images/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +httpx==0.26.0 +pillow==10.2.0 +pillow-heif==0.20.0 +aiofiles==23.2.1 +python-multipart==0.0.6 +pydantic==2.5.3 +pydantic-settings==2.1.0 +motor==3.3.2 +redis==5.0.1 \ No newline at end of file