From e984d2da018575a71955326dc244d73199ae5faa Mon Sep 17 00:00:00 2001 From: kir1903 Date: Thu, 10 Sep 2026 22:50:58 +0700 Subject: [PATCH] Add avatar generator microservice app --- .gitignore | 2 + .../backend/Dockerfile" | 16 +++ .../backend/app.py" | 47 +++++++ .../backend/celery_app.py" | 19 +++ .../backend/requirements.txt" | 5 + .../backend/tasks.py" | 127 ++++++++++++++++++ .../backend/wsgi.py" | 4 + .../docker-compose.yml" | 48 +++++++ .../frontend/Dockerfile" | 5 + .../frontend/app.js" | 55 ++++++++ .../frontend/index.html" | 25 ++++ .../frontend/style.css" | 76 +++++++++++ .../nginx/nginx.conf" | 23 ++++ .../readme.md" | 72 ++++++++++ 14 files changed, 524 insertions(+) create mode 100644 .gitignore create mode 100644 "\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/Dockerfile" create mode 100644 "\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/app.py" create mode 100644 "\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/celery_app.py" create mode 100644 "\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/requirements.txt" create mode 100644 "\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/tasks.py" create mode 100644 "\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/wsgi.py" create mode 100644 "\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/docker-compose.yml" create mode 100644 "\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/Dockerfile" create mode 100644 "\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/app.js" create mode 100644 "\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/index.html" create mode 100644 "\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/style.css" create mode 100644 "\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/nginx/nginx.conf" create mode 100644 "\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/readme.md" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git "a/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/Dockerfile" "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/Dockerfile" new file mode 100644 index 0000000..3df8b5c --- /dev/null +++ "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/Dockerfile" @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /app/media + +EXPOSE 5000 diff --git "a/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/app.py" "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/app.py" new file mode 100644 index 0000000..d0f5c2c --- /dev/null +++ "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/app.py" @@ -0,0 +1,47 @@ +import os + +from celery.result import AsyncResult +from flask import Flask, jsonify, request, send_from_directory + +from celery_app import celery_app +from tasks import generate_avatar + +MEDIA_DIR = os.environ.get("MEDIA_DIR", "/app/media") + +app = Flask(__name__) + + +@app.post("/api/avatars") +def create_avatar(): + data = request.get_json(silent=True) or {} + name = (data.get("name") or "").strip() + if not name: + return jsonify({"error": "Поле 'name' обязательно"}), 400 + + task = generate_avatar.delay(name) + return jsonify({"task_id": task.id}), 202 + + +@app.get("/api/avatars/") +def get_avatar_status(task_id): + result = AsyncResult(task_id, app=celery_app) + + if result.state in ("PENDING", "STARTED"): + return jsonify({"status": "pending"}) + if result.state == "FAILURE": + return jsonify({"status": "failure", "error": str(result.result)}), 500 + if result.state == "SUCCESS": + filename = result.result["filename"] + return jsonify({"status": "success", "avatar_url": f"/api/media/{filename}"}) + + return jsonify({"status": result.state.lower()}) + + +@app.get("/api/media/") +def get_media(filename): + return send_from_directory(MEDIA_DIR, filename) + + +@app.get("/api/health") +def health(): + return jsonify({"status": "ok"}) diff --git "a/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/celery_app.py" "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/celery_app.py" new file mode 100644 index 0000000..9263af3 --- /dev/null +++ "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/celery_app.py" @@ -0,0 +1,19 @@ +import os + +from celery import Celery + +REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379/0") + +celery_app = Celery( + "avatars", + broker=REDIS_URL, + backend=REDIS_URL, + include=["tasks"], +) + +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + result_expires=3600, +) diff --git "a/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/requirements.txt" "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/requirements.txt" new file mode 100644 index 0000000..ca7f763 --- /dev/null +++ "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/requirements.txt" @@ -0,0 +1,5 @@ +flask==3.0.3 +gunicorn==22.0.0 +celery==5.4.0 +redis==5.0.8 +Pillow==10.4.0 diff --git "a/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/tasks.py" "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/tasks.py" new file mode 100644 index 0000000..b42876a --- /dev/null +++ "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/tasks.py" @@ -0,0 +1,127 @@ +import colorsys +import hashlib +import os +import time +import uuid + +from PIL import Image, ImageDraw, ImageFont + +from celery_app import celery_app + +MEDIA_DIR = os.environ.get("MEDIA_DIR", "/app/media") +AVATAR_SIZE = 256 + +FONT_CANDIDATES = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", +] + + +def _initials(name: str) -> str: + parts = [p for p in name.strip().split() if p] + if not parts: + return "?" + if len(parts) == 1: + return parts[0][0].upper() + return (parts[0][0] + parts[1][0]).upper() + + +def _hsl_to_rgb(hue_deg: float, saturation: float, lightness: float) -> tuple: + r, g, b = colorsys.hls_to_rgb((hue_deg % 360) / 360, lightness, saturation) + return (int(r * 255), int(g * 255), int(b * 255)) + + +def _gradient_colors(digest: str) -> tuple: + hue1 = int(digest[0:4], 16) % 360 + hue2 = int(digest[4:8], 16) % 360 + if abs(hue1 - hue2) < 40: + hue2 = (hue2 + 90) % 360 + + color1 = _hsl_to_rgb(hue1, 0.65, 0.55) + color2 = _hsl_to_rgb(hue2, 0.65, 0.40) + return color1, color2 + + +def _diagonal_gradient(size: int, color1: tuple, color2: tuple) -> Image.Image: + base = Image.new("RGB", (size, size), color1) + top = Image.new("RGB", (size, size), color2) + + mask = Image.new("L", (size, size)) + mask_data = [ + int((x + y) / (2 * (size - 1)) * 255) + for y in range(size) + for x in range(size) + ] + mask.putdata(mask_data) + + return Image.composite(top, base, mask) + + +def _add_pattern(image: Image.Image, digest: str) -> Image.Image: + size = image.size[0] + values = [int(digest[i : i + 2], 16) for i in range(8, 24, 2)] + + overlay = Image.new("RGBA", image.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + for i in range(3): + vx = values[i % len(values)] + vy = values[(i + 1) % len(values)] + vr = values[(i + 2) % len(values)] + valpha = values[(i + 3) % len(values)] + + cx = int(vx / 255 * size) + cy = int(vy / 255 * size) + radius = int(size * 0.15 + (vr / 255) * size * 0.2) + alpha = 25 + int(valpha / 255 * 35) + + draw.ellipse( + [cx - radius, cy - radius, cx + radius, cy + radius], + fill=(255, 255, 255, alpha), + ) + + return Image.alpha_composite(image.convert("RGBA"), overlay) + + +def _load_font(size: int): + for path in FONT_CANDIDATES: + if os.path.exists(path): + return ImageFont.truetype(path, size) + return ImageFont.load_default() + + +@celery_app.task(name="tasks.generate_avatar") +def generate_avatar(name: str) -> dict: + # Имитация длительной обработки — генерация вынесена в отдельный worker-сервис. + time.sleep(2) + + os.makedirs(MEDIA_DIR, exist_ok=True) + + digest = hashlib.md5(name.encode("utf-8")).hexdigest() + initials = _initials(name) + color1, color2 = _gradient_colors(digest) + + image = _diagonal_gradient(AVATAR_SIZE, color1, color2) + image = _add_pattern(image, digest) + + draw = ImageDraw.Draw(image) + font = _load_font(AVATAR_SIZE // 2) + bbox = draw.textbbox((0, 0), initials, font=font) + text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1] + position = ( + (AVATAR_SIZE - text_w) / 2 - bbox[0], + (AVATAR_SIZE - text_h) / 2 - bbox[1], + ) + shadow_offset = 2 + draw.text( + (position[0] + shadow_offset, position[1] + shadow_offset), + initials, + fill=(0, 0, 0, 90), + font=font, + ) + draw.text(position, initials, fill="white", font=font) + + filename = f"{uuid.uuid4().hex}.png" + filepath = os.path.join(MEDIA_DIR, filename) + image.convert("RGB").save(filepath, format="PNG") + + return {"filename": filename} diff --git "a/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/wsgi.py" "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/wsgi.py" new file mode 100644 index 0000000..20f2a4f --- /dev/null +++ "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/backend/wsgi.py" @@ -0,0 +1,4 @@ +from app import app + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git "a/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/docker-compose.yml" "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/docker-compose.yml" new file mode 100644 index 0000000..51f3826 --- /dev/null +++ "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/docker-compose.yml" @@ -0,0 +1,48 @@ +name: avatar-generator + +services: + redis: + image: redis:7-alpine + restart: unless-stopped + + backend: + build: ./backend + command: gunicorn wsgi:app --bind 0.0.0.0:5000 --workers 2 + environment: + REDIS_URL: redis://redis:6379/0 + MEDIA_DIR: /app/media + volumes: + - avatars_data:/app/media + depends_on: + - redis + restart: unless-stopped + + worker: + build: ./backend + command: celery -A celery_app worker --loglevel=info + environment: + REDIS_URL: redis://redis:6379/0 + MEDIA_DIR: /app/media + volumes: + - avatars_data:/app/media + depends_on: + - redis + restart: unless-stopped + + frontend: + build: ./frontend + restart: unless-stopped + + nginx: + image: nginx:1.27-alpine + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + ports: + - "8080:80" + depends_on: + - backend + - frontend + restart: unless-stopped + +volumes: + avatars_data: diff --git "a/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/Dockerfile" "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/Dockerfile" new file mode 100644 index 0000000..a2b48bc --- /dev/null +++ "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/Dockerfile" @@ -0,0 +1,5 @@ +FROM nginx:1.27-alpine + +COPY index.html style.css app.js /usr/share/nginx/html/ + +EXPOSE 80 diff --git "a/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/app.js" "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/app.js" new file mode 100644 index 0000000..fe16559 --- /dev/null +++ "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/app.js" @@ -0,0 +1,55 @@ +const form = document.getElementById("avatar-form"); +const nameInput = document.getElementById("name-input"); +const statusEl = document.getElementById("status"); +const resultEl = document.getElementById("result"); + +form.addEventListener("submit", async (event) => { + event.preventDefault(); + const name = nameInput.value.trim(); + if (!name) return; + + resultEl.innerHTML = ""; + statusEl.textContent = "Отправка задачи..."; + + try { + const response = await fetch("/api/avatars", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || "Ошибка сервера"); + } + + const { task_id } = await response.json(); + statusEl.textContent = "Аватар генерируется..."; + pollStatus(task_id); + } catch (error) { + statusEl.textContent = `Ошибка: ${error.message}`; + } +}); + +function pollStatus(taskId) { + const interval = setInterval(async () => { + try { + const response = await fetch(`/api/avatars/${taskId}`); + const data = await response.json(); + + if (data.status === "success") { + clearInterval(interval); + statusEl.textContent = "Готово!"; + resultEl.innerHTML = `Аватар`; + } else if (data.status === "failure") { + clearInterval(interval); + statusEl.textContent = `Ошибка генерации: ${data.error || ""}`; + } else { + statusEl.textContent = "Аватар генерируется..."; + } + } catch (error) { + clearInterval(interval); + statusEl.textContent = `Ошибка: ${error.message}`; + } + }, 1000); +} diff --git "a/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/index.html" "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/index.html" new file mode 100644 index 0000000..34962b2 --- /dev/null +++ "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/index.html" @@ -0,0 +1,25 @@ + + + + + + Генератор аватарок по имени + + + +
+

Генератор аватарок

+

Введите имя — и получите аватар с инициалами на цветном фоне.

+ +
+ + +
+ +
+
+
+ + + + diff --git "a/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/style.css" "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/style.css" new file mode 100644 index 0000000..666dc8d --- /dev/null +++ "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/frontend/style.css" @@ -0,0 +1,76 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #6366f1, #ec4899); + font-family: "Segoe UI", Roboto, Arial, sans-serif; +} + +.card { + background: #ffffff; + border-radius: 16px; + padding: 32px 40px; + max-width: 420px; + width: 90%; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); + text-align: center; +} + +h1 { + margin-top: 0; + color: #1f2937; +} + +p { + color: #6b7280; + font-size: 14px; +} + +form { + display: flex; + gap: 8px; + margin: 20px 0; +} + +input[type="text"] { + flex: 1; + padding: 10px 12px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 14px; +} + +button { + padding: 10px 16px; + border: none; + border-radius: 8px; + background: #6366f1; + color: white; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +button:hover { + background: #4f46e5; +} + +.status { + min-height: 20px; + color: #374151; + font-size: 14px; + margin-bottom: 12px; +} + +.avatar { + width: 160px; + height: 160px; + border-radius: 50%; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); +} diff --git "a/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/nginx/nginx.conf" "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/nginx/nginx.conf" new file mode 100644 index 0000000..f4efa49 --- /dev/null +++ "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/nginx/nginx.conf" @@ -0,0 +1,23 @@ +upstream frontend_upstream { + server frontend:80; +} + +upstream backend_upstream { + server backend:5000; +} + +server { + listen 80; + + location /api/ { + proxy_pass http://backend_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location / { + proxy_pass http://frontend_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} diff --git "a/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/readme.md" "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/readme.md" new file mode 100644 index 0000000..6cac3c0 --- /dev/null +++ "b/\320\240\321\203\320\272\320\276\321\201\321\203\320\265\320\262\320\260\320\225\320\224/readme.md" @@ -0,0 +1,72 @@ +# Генератор аватарок по имени + +## Описание + +Веб-приложение, которое генерирует круглый аватар по введённому имени: градиентный фон и лёгкий геометрический узор (пятна разного размера и прозрачности) детерминированно строятся по MD5-хешу имени, поверх — инициалы. Одно и то же имя всегда даёт один и тот же аватар, а разные имена — визуально разные, а не выбор из фиксированного набора цветов. + +Приложение построено как набор независимых сервисов, взаимодействующих через Docker-сеть, с единой внешней точкой входа через Nginx. + +## Архитектура + +```mermaid +graph TD + User([Браузер пользователя]) + + subgraph "Docker сеть" + Nginx[Nginx — точка входа] + Frontend[Frontend: статика] + Backend["Backend (WSGI): Flask + Gunicorn"] + Worker[Worker: Celery] + Redis[(Redis — очередь задач)] + Volume[(Общий volume: media)] + end + + User -->|":8080"| Nginx + Nginx -->|"/"| Frontend + Nginx -->|"/api/"| Backend + Backend -->|"ставит задачу"| Redis + Redis -->|"забирает задачу"| Worker + Worker -->|"сохраняет PNG"| Volume + Backend -->|"читает результат"| Redis + Backend -->|"отдаёт файл"| Volume +``` + +Компоненты: + +- **frontend** — статическая HTML/CSS/JS-страница (форма ввода имени, опрос статуса задачи, показ готового аватара). Раздаётся собственным nginx-контейнером. +- **backend** (WSGI, Flask + Gunicorn) — принимает запрос на генерацию, ставит задачу в очередь Celery/Redis и отдаёт результат по `task_id`. Сам аватары не рисует. +- **worker** (Celery) — забирает задачи из Redis и выполняет длительную операцию генерации изображения (`Pillow`), имитируя реальную нагрузку задержкой в 2 секунды. Работает в отдельном сервисе, не блокируя backend. +- **redis** — брокер сообщений и хранилище результатов Celery. +- **nginx** — единственная точка входа приложения, проксирует `/` на frontend и `/api/` на backend. + +## API + +| Метод | Путь | Описание | +|-------|--------------------------|--------------------------------------------------------| +| POST | `/api/avatars` | Тело `{"name": "Имя Фамилия"}`, возвращает `{"task_id": "..."}` (202) | +| GET | `/api/avatars/` | Статус задачи: `pending` / `success` (+`avatar_url`) / `failure` | +| GET | `/api/media/` | Отдаёт сгенерированный PNG-файл | +| GET | `/api/health` | Проверка живости backend | + +## Запуск + +```bash +docker compose up --build +``` + +После запуска приложение доступно по адресу: + +``` +http://localhost:8080 +``` + +Введите имя в форму — через пару секунд появится сгенерированный аватар. + +## Технологии + +- Backend: Python, Flask, Gunicorn (WSGI) +- Очередь длительных задач: Celery, Redis +- Генерация изображений: Pillow +- Frontend: HTML, CSS, vanilla JavaScript +- Точка входа: Nginx +- Оркестрация: Docker Compose