diff --git a/backend/README.md b/backend/README.md index 79c8f75..6828539 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,21 +1,25 @@ # FoodAI Backend -Go REST API с авторизацией через Firebase, JWT и PostgreSQL. Включает инфраструктуру импорта и перевода справочных данных (ингредиенты, рецепты). +Go REST API с авторизацией через Firebase и JWT, асинхронным распознаванием блюд через Gemini AI, и каталогом продуктов питания на базе OpenFoodFacts. ## Стек -- **Go 1.23** — язык -- **chi** — HTTP-роутер -- **pgx / pgxpool** — PostgreSQL-драйвер -- **goose** — миграции +- **Go 1.25.5** — язык +- **chi v5** — HTTP-роутер +- **pgx v5 / pgxpool** — драйвер PostgreSQL +- **goose v3** — миграции - **golang-jwt/v5** — JWT - **Firebase Admin SDK** — верификация токенов -- **cobra** — CLI для команд импорта -- **generative-ai-go** — Gemini API (переводы) +- **franz-go** — Kafka-продюсер и консьюмер (pure Go) +- **watermill + watermill-kafka** — маршрутизация событий (воркер) +- **envconfig** — конфигурация через переменные окружения +- **testcontainers-go** — интеграционные тесты с реальной БД +- **generative-ai-go / OpenAI API** — распознавание блюд, генерация рецептов и меню +- **Pexels API** — изображения для рецептов ## Требования -- Go 1.23+ +- Go 1.25+ - Docker & Docker Compose - [goose](https://github.com/pressly/goose) (`go install github.com/pressly/goose/v3/cmd/goose@latest`) - Файл `firebase-credentials.json` (сервисный аккаунт Firebase) @@ -32,73 +36,62 @@ cp .env.example .env | Переменная | Описание | По умолчанию | |---|---|---| -| `DATABASE_URL` | DSN подключения к PostgreSQL | `postgres://food_ai:food_ai_local@localhost:5432/food_ai?sslmode=disable` | +| `DATABASE_URL` | DSN подключения к PostgreSQL | `postgres://food_ai:food_ai_local@localhost:5433/food_ai?sslmode=disable` | | `FIREBASE_CREDENTIALS_FILE` | Путь к JSON-ключу сервисного аккаунта Firebase | `./firebase-credentials.json` | | `JWT_SECRET` | Секрет для подписи JWT | — | -| `JWT_ACCESS_DURATION` | Время жизни access-токена | `15m` | -| `JWT_REFRESH_DURATION` | Время жизни refresh-токена | `720h` | -| `PORT` | Порт сервера | `8080` | +| `JWT_ACCESS_DURATION` | TTL access-токена | `15m` | +| `JWT_REFRESH_DURATION` | TTL refresh-токена | `720h` | +| `PORT` | Порт HTTP-сервера | `8080` | | `ALLOWED_ORIGINS` | CORS-разрешённые источники | `http://localhost:3000` | -| `SPOONACULAR_API_KEY` | Ключ Spoonacular API (нужен для команд `import`) | — | -| `GEMINI_API_KEY` | Ключ Gemini API (нужен для команд `translate`) | — | +| `KAFKA_BROKERS` | Адреса Kafka-брокеров | `localhost:9093` (dev), `kafka:9092` (Docker) | +| `OPENAI_API_KEY` | API-ключ Gemini/OpenAI (распознавание, генерация) | — | +| `PEXELS_API_KEY` | API-ключ Pexels (изображения для рецептов) | — | ### 2. Запуск через Docker Compose -Поднимает PostgreSQL и собирает приложение: +Поднимает PostgreSQL, Kafka, сервер и оба воркера: ```bash -make docker-up +OPENAI_API_KEY=your-key make docker-up ``` -### 3. Запуск локально (только БД в Docker) +### 3. Запуск локально (инфраструктура в Docker) ```bash -# Запустить только PostgreSQL -docker compose up -d postgres +# Поднять только PostgreSQL + Kafka +make dev-infra-up # Применить миграции make migrate-up -# Запустить сервер -make run +# Запустить всё локально (сервер + оба воркера + инфраструктура) +make dev ``` -### 4. Запуск воркера - -Сервер (`cmd/server`) и воркер (`cmd/worker`) — два отдельных процесса: - -- **Сервер** — обслуживает HTTP API и SSE-стримы; публикует задания распознавания в Kafka. -- **Воркер** — потребляет задания из Kafka, вызывает AI, сохраняет результаты в БД и оповещает сервер через `pg_notify`. - -**Локально** (инфраструктура в Docker, процессы локально): +Или по отдельности в разных терминалах: ```bash -# Поднять Kafka + PostgreSQL -docker compose up -d postgres kafka kafka-init - -# В отдельном терминале — сервер -make run - -# В другом терминале — воркер -go run ./cmd/worker +make run # HTTP-сервер (порт 8080) +make run-worker-paid # Воркер paid-очереди +make run-worker-free # Воркер free-очереди ``` +### 4. Сервер и воркеры + +Два типа процессов работают независимо: + +- **Сервер** (`cmd/server`) — обслуживает HTTP API и SSE-стримы; публикует задания распознавания в Kafka. +- **Воркер** (`cmd/worker`) — потребляет задания из Kafka, вызывает AI, сохраняет результаты в БД и оповещает сервер через `pg_notify`. + +Воркеры можно масштабировать горизонтально — они разделят нагрузку через consumer group `dish-recognition-workers`. + **Переменные окружения воркера:** | Переменная | Описание | |---|---| | `DATABASE_URL` | DSN подключения к PostgreSQL | -| `OPENAI_API_KEY` | Ключ Gemini API (для распознавания блюд) | -| `KAFKA_BROKERS` | Адреса Kafka-брокеров (по умолчанию `kafka:9092`) | - -**Через Docker Compose** (сервер + воркер + инфраструктура): - -```bash -OPENAI_API_KEY=your-key docker compose up app worker -``` - -Воркеры можно масштабировать горизонтально — запустите несколько контейнеров `worker`, -они разделят нагрузку через consumer group `dish-recognition-workers`. +| `OPENAI_API_KEY` | API-ключ Gemini/OpenAI | +| `KAFKA_BROKERS` | Адреса Kafka-брокеров | ## Команды @@ -106,69 +99,116 @@ OPENAI_API_KEY=your-key docker compose up app worker | Команда | Описание | |---|---| -| `make run` | Запустить сервер в режиме разработки | -| `make test` | Unit-тесты | +| `make run` | Запустить HTTP-сервер (dev) | +| `make run-worker-paid` | Запустить воркер paid-очереди | +| `make run-worker-free` | Запустить воркер free-очереди | +| `make dev` | Поднять инфраструктуру + сервер + оба воркера | +| `make dev-infra-up` | Поднять только PostgreSQL + Kafka | +| `make dev-infra-down` | Остановить инфраструктуру | +| `make test` | Unit-тесты (с race detector) | | `make test-integration` | Интеграционные тесты (требует Docker) | | `make lint` | Проверка через golangci-lint | -| `make docker-up` | Поднять PostgreSQL + приложение | -| `make docker-down` | Остановить контейнеры | -| `make docker-logs` | Логи приложения | +| `make docker-up` | Полный Docker Compose (app + workers + infra) | +| `make docker-down` | Остановить все контейнеры | +| `make docker-logs` | Логи app-контейнера | +| `make docker-logs-worker` | Логи worker-контейнеров | | `make migrate-up` | Применить миграции | | `make migrate-down` | Откатить последнюю миграцию | | `make migrate-status` | Статус миграций | | `make migrate-create name=` | Создать новую миграцию | -### Импорт данных (требует `SPOONACULAR_API_KEY`) +## Импорт каталога продуктов -| Команда | Описание | -|---|---| -| `make import-ingredients` | Импортировать ~1 000 ингредиентов | -| `make import-recipes` | Импортировать ~5 000 рецептов | -| `make import-recipes-full` | Импортировать ~10 000 рецептов | - -### Перевод (требует `GEMINI_API_KEY`) - -| Команда | Описание | -|---|---| -| `make translate-recipes` | Перевести рецепты на русский | -| `make translate-ingredients` | Перевести топ-200 ингредиентов | -| `make import-all` | Полный пайплайн: ингредиенты → рецепты → переводы | - -Все команды импорта идемпотентны (`ON CONFLICT DO UPDATE`) — можно запускать повторно. Для возобновления прерванного импорта используйте флаги `--skip-queries` / `--offset`. - -### CLI напрямую +Каталог продуктов заполняется из дампа [OpenFoodFacts](https://world.openfoodfacts.org/data). ```bash -# Тестовый прогон (без сохранения в БД) -go run ./cmd/import import ingredients --limit 50 --dry-run -go run ./cmd/import import recipes --count 100 --dry-run +# Скачать дамп (~66 ГБ, ~4.4 млн записей) +# https://world.openfoodfacts.org/data/openfoodfacts-products.jsonl.gz -# Возобновление импорта -go run ./cmd/import import ingredients --limit 1000 --skip-queries 10 -go run ./cmd/import import recipes --count 5000 --offset 2000 - -# Перевод части рецептов -go run ./cmd/import translate recipes --limit 1000 -go run ./cmd/import translate ingredients --top 50 +go run ./cmd/importoff \ + -file=openfoodfacts-products.jsonl.gz \ + -dsn="$DATABASE_URL" \ + -min-scans=1 ``` +**Флаги:** + +| Флаг | Описание | По умолчанию | +|---|---|---| +| `-file` | Путь к JSONL или JSONL.GZ файлу | обязательный | +| `-dsn` | PostgreSQL DSN (или `$DATABASE_URL`) | `$DATABASE_URL` | +| `-limit` | Остановиться после N принятых записей (0 = без ограничения) | `0` | +| `-batch` | Записей на один upsert-батч | `500` | +| `-min-scans` | Минимальное число уникальных сканирований (0 = без фильтра) | `1` | + +**Алгоритм:** + +1. Создаёт временную staging-таблицу без уникальных ограничений. +2. Читает файл построчно (gzip-прозрачно); пропускает записи без баркода, названия или калорий. +3. Дедуплицирует `canonical_name` внутри батча. +4. Загружает батч через `COPY` (без ошибок на дублях). +5. Обнуляет barcodes, уже занятые другой записью в каталоге. +6. `INSERT … ON CONFLICT (canonical_name) DO UPDATE` — безопасное слияние. + +Команда идемпотентна — можно запускать повторно и прерывать. + ## API ### Публичные эндпоинты | Метод | Путь | Описание | |---|---|---| -| `GET` | `/health` | Проверка состояния сервера и БД | -| `POST` | `/auth/login` | Вход через Firebase ID-токен | -| `POST` | `/auth/refresh` | Обновление JWT по refresh-токену | +| `GET` | `/health` | Статус сервера и БД | +| `GET` | `/languages` | Список поддерживаемых языков | +| `GET` | `/units` | Справочник единиц измерения | +| `GET` | `/cuisines` | Список кухонь | +| `GET` | `/tags` | Теги рецептов и блюд | +| `POST` | `/auth/login` | Вход через Firebase ID-токен → JWT | +| `POST` | `/auth/refresh` | Обновить JWT по refresh-токену | | `POST` | `/auth/logout` | Выход (инвалидация refresh-токена) | ### Защищённые эндпоинты (Bearer JWT) | Метод | Путь | Описание | |---|---|---| -| `GET` | `/profile` | Получить профиль пользователя | -| `PUT` | `/profile` | Обновить профиль пользователя | +| `GET/PUT` | `/profile` | Профиль пользователя | +| `GET` | `/products/search?q=&limit=` | Поиск в каталоге продуктов | +| `GET` | `/products/barcode/{barcode}` | Поиск по баркоду (OpenFoodFacts как fallback) | +| `GET/POST/DELETE` | `/user-products`, `/user-products/{id}` | Продукты пользователя (холодильник) | +| `POST` | `/user-products/batch` | Массовое добавление продуктов | +| `GET` | `/diary?date=YYYY-MM-DD` | Записи дневника питания за дату | +| `POST/DELETE` | `/diary`, `/diary/{id}` | Добавить / удалить запись дневника | +| `GET` | `/home/summary` | Сводка для главного экрана | +| `GET` | `/recommendations` | AI-рекомендации блюд | +| `GET/POST/DELETE` | `/saved-recipes`, `/saved-recipes/{id}` | Сохранённые рецепты | +| `GET/PUT/DELETE` | `/menu/items/{id}` | Меню на неделю | +| `GET/POST/PATCH` | `/shopping-list`, `/shopping-list/{i}/check` | Список покупок | +| `GET` | `/dishes/{id}` | Блюдо по ID | +| `GET` | `/recipes/{id}` | Рецепт по ID | +| `POST` | `/ai/recognize-receipt` | Распознать чек (синхронно) | +| `POST` | `/ai/recognize-products` | Распознать продукты на фото (синхронно) | +| `POST` | `/ai/recognize-dish` | Распознать блюдо — 202 + `job_id` (асинхронно) | +| `GET` | `/ai/jobs` | Задания за сегодня | +| `GET` | `/ai/jobs/history` | Все задания пользователя | +| `GET` | `/ai/jobs/{id}` | Результат задания | +| `GET` | `/ai/jobs/{id}/stream` | SSE-стрим обновлений задания | +| `POST` | `/ai/generate-menu` | Сгенерировать меню через AI | + +### Асинхронное распознавание блюд + +`POST /ai/recognize-dish` ставит задание в очередь и сразу возвращает: + +```json +{"job_id": "...", "queue_position": 2, "estimated_seconds": 18} +``` + +Статус отслеживается через SSE: `GET /ai/jobs/{id}/stream` — события `queued`, `processing`, `done`, `failed`. + +При повторном открытии приложения результат можно получить через `GET /ai/jobs/{id}`. + +Две очереди с разным приоритетом обслуживания: +- `ai.recognize.paid` (3 партиции) — платные пользователи +- `ai.recognize.free` (1 партиция) — бесплатные пользователи ### Примеры запросов @@ -179,18 +219,24 @@ curl -X POST http://localhost:8080/auth/login \ -d '{"firebase_token": ""}' ``` -**Получить профиль:** +**Поиск продукта:** ```bash -curl http://localhost:8080/profile \ +curl "http://localhost:8080/products/search?q=butter&limit=5" \ -H "Authorization: Bearer " ``` -**Обновить профиль:** +**Поиск по баркоду:** ```bash -curl -X PUT http://localhost:8080/profile \ +curl http://localhost:8080/products/barcode/3017620422003 \ + -H "Authorization: Bearer " +``` + +**Добавить запись в дневник:** +```bash +curl -X POST http://localhost:8080/diary \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ - -d '{"height_cm": 180, "weight_kg": 75.5, "age": 28, "gender": "male", "activity": "moderate", "goal": "maintain"}' + -d '{"date": "2026-03-21", "meal_type": "lunch", "product_id": "...", "portion_g": 150}' ``` ## Структура проекта @@ -198,24 +244,38 @@ curl -X PUT http://localhost:8080/profile \ ``` backend/ ├── cmd/ -│ ├── server/ # HTTP-сервер (точка входа) -│ └── import/ # CLI для импорта и перевода данных (Cobra) +│ ├── server/ # HTTP-сервер (точка входа) +│ ├── worker/ # Async-воркер распознавания блюд (Kafka) +│ └── importoff/ # Импорт каталога продуктов из OpenFoodFacts JSONL ├── internal/ -│ ├── auth/ # Firebase-верификация, JWT, сервис и хэндлер авторизации -│ ├── config/ # Конфигурация через переменные окружения -│ ├── database/ # Подключение к PostgreSQL (pgxpool) -│ ├── ingredient/ # Модель, репозиторий, сервис импорта ингредиентов -│ ├── middleware/ # RequestID, Logging, Recovery, CORS, Auth -│ ├── recipe/ # Модель, репозиторий, сервис импорта рецептов -│ ├── server/ # Роутер (chi) -│ ├── spoonacular/ # HTTP-клиент Spoonacular API (интерфейс + реализация) -│ ├── testutil/ # Вспомогательные утилиты для тестов -│ ├── translation/ # Gemini-переводчик, сервис батчевого перевода -│ └── user/ # Модель, репозиторий, сервис, хэндлер, расчёт калорий +│ ├── domain/ +│ │ ├── auth/ # Firebase-верификация, JWT, login/refresh/logout +│ │ ├── user/ # Профиль, калькулятор КБЖУ +│ │ ├── product/ # Каталог продуктов (поиск, баркод, OFF-адаптер) +│ │ ├── userproduct/ # Продукты пользователя (холодильник/список) +│ │ ├── recipe/ # Каталог рецептов с переводами +│ │ ├── savedrecipe/ # Сохранённые рецепты пользователя +│ │ ├── dish/ # Блюда, рецепты блюд, переводы +│ │ ├── menu/ # Меню на неделю, список покупок +│ │ ├── diary/ # Дневник питания +│ │ ├── recognition/ # Async распознавание: jobs, SSE-broker, Kafka-воркер +│ │ ├── recommendation/ # AI-рекомендации +│ │ └── home/ # Сводка главного экрана +│ ├── adapters/ +│ │ ├── ai/ # Общие типы AI-ответов +│ │ ├── openai/ # Gemini/OpenAI клиент (распознавание, генерация) +│ │ ├── firebase/ # Верификация Firebase-токенов +│ │ ├── kafka/ # Продюсер и консьюмер (franz-go) +│ │ └── pexels/ # Поиск изображений через Pexels API +│ └── infra/ +│ ├── config/ # Конфигурация (envconfig) +│ ├── database/ # pgxpool-соединение +│ ├── locale/ # Определение языка из заголовка Accept-Language +│ ├── middleware/ # RequestID, Logging, Recovery, CORS, Auth, Language +│ └── server/ # Chi-роутер со всеми маршрутами ├── migrations/ -│ ├── 001_create_users.sql -│ ├── 002_create_ingredient_mappings.sql # GIN-индекс по aliases -│ └── 003_create_recipes.sql # FTS + GIN по ingredients/tags +│ └── 001_initial_schema.sql # Полная схема БД +├── tests/ # Интеграционные и unit-тесты по доменам ├── .env.example ├── docker-compose.yml ├── Dockerfile @@ -224,58 +284,28 @@ backend/ ## Схема БД -### `ingredient_mappings` +Вся схема содержится в одном файле `migrations/001_initial_schema.sql`. Основные таблицы: -Канонический справочник ингредиентов. Каждая запись — один вид продукта. +- `users`, `refresh_tokens` — пользователи и токены +- `products`, `product_categories`, `product_category_translations` — каталог продуктов +- `user_products` — продукты пользователя +- `ingredients`, `ingredient_translations`, `ingredient_aliases` — справочник ингредиентов +- `dishes`, `dish_translations`, `recipes`, `recipe_steps`, `recipe_ingredients` — блюда и рецепты +- `saved_recipes`, `saved_recipe_translations` — сохранённые рецепты +- `meal_diary` — дневник питания +- `recognition_jobs` — задания AI-распознавания +- `cuisines`, `cuisine_translations`, `tags`, `tag_translations`, `units`, `unit_translations` — справочники -| Поле | Тип | Описание | -|---|---|---| -| `id` | UUID | Первичный ключ | -| `canonical_name` | varchar | Нормализованное EN-название (`chicken_breast`) | -| `canonical_name_ru` | varchar | Русское название (`куриная грудка`) | -| `spoonacular_id` | integer | Уникальный ID из Spoonacular | -| `aliases` | JSONB | Массив альтернативных названий (EN + RU) | -| `category` | varchar | `produce`, `dairy`, `meat`, `seafood`, `grains`, `spices`, `canned`, `frozen`, `beverages`, `other` | -| `default_unit` | varchar | Единица измерения по умолчанию (`g`, `ml`) | -| `calories_per_100g` | decimal | Нутриенты на 100 г | -| `storage_days` | integer | Типичный срок хранения (дни) | - -Индексы: GIN по `aliases` (поиск `@>`), `canonical_name`, `category`, UNIQUE по `spoonacular_id`. - -### `recipes` - -Каталог рецептов. Заполняется из Spoonacular, переводится через Gemini. - -| Поле | Тип | Описание | -|---|---|---| -| `id` | UUID | Первичный ключ | -| `source` | enum | `spoonacular`, `ai`, `user` | -| `spoonacular_id` | integer | Уникальный ID из Spoonacular | -| `title` / `title_ru` | varchar | Название EN/RU | -| `difficulty` | enum | `easy` (≤30 мин), `medium` (≤60 мин), `hard` | -| `ingredients` | JSONB | Массив `{spoonacular_id, mapping_id, name, amount, unit}` | -| `steps` | JSONB | Массив `{number, description, description_ru, timer_seconds}` | -| `tags` | JSONB | `["vegetarian", "gluten-free", "meal:dinner", ...]` | -| `calories_per_serving` | decimal | Нутриенты на порцию | -| `avg_rating` / `review_count` | decimal/int | Рейтинг (обновляется при отзывах) | - -Индексы: GIN по `ingredients` (поиск по `mapping_id`), GIN по `tags`, FTS по `title + title_ru`. +Для каждой таблицы с пользовательским текстом используется паттерн `{table}_translations` (companion table). Базовые таблицы хранят английский canonical text, переводы — только в `_translations` таблицах. ## Тесты ```bash -# Unit-тесты (~69 тестов, ~13 сек) +# Unit-тесты (с race detector) make test -# Интеграционные тесты (PostgreSQL в Docker через testcontainers) +# Интеграционные тесты (PostgreSQL поднимается через testcontainers) make test-integration ``` -| Пакет | Unit | Integration | -|---|---|---| -| `auth` | 17 | 10 | -| `ingredient` | 9 | 5 | -| `middleware` | 10 | — | -| `recipe` | 12 | 7 | -| `translation` | 6 | — | -| `user` | 14 | 12 | +Покрытые пакеты: `auth`, `diary`, `middleware`, `recognition`, `recipe`, `savedrecipe`, `user`, `userproduct`, `locale`, `menu`.