docs: rewrite backend README to match current state

- Update stack: Go 1.25.5, add franz-go/watermill/envconfig/testcontainers/Pexels, remove cobra/Spoonacular
- Update env vars: replace SPOONACULAR_API_KEY/GEMINI_API_KEY with OPENAI_API_KEY/PEXELS_API_KEY/KAFKA_BROKERS
- Remove outdated Spoonacular import/translate commands
- Add OpenFoodFacts import section (cmd/importoff flags and algorithm)
- Add full API endpoint table including diary, user-products, AI recognition
- Add async dish recognition flow (SSE, two Kafka queues)
- Update project structure tree (domain/, adapters/, infra/)
- Replace detailed stale DB schema with high-level table list
- Remove fragile exact test counts
- gitignore: add importoff binary and OFF data files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-21 13:06:26 +02:00
parent 627fdfd81e
commit 31a4179ac6

View File

@@ -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=<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": "<FIREBASE_ID_TOKEN>"}'
```
**Получить профиль:**
**Поиск продукта:**
```bash
curl http://localhost:8080/profile \
curl "http://localhost:8080/products/search?q=butter&limit=5" \
-H "Authorization: Bearer <ACCESS_TOKEN>"
```
**Обновить профиль:**
**Поиск по баркоду:**
```bash
curl -X PUT http://localhost:8080/profile \
curl http://localhost:8080/products/barcode/3017620422003 \
-H "Authorization: Bearer <ACCESS_TOKEN>"
```
**Добавить запись в дневник:**
```bash
curl -X POST http://localhost:8080/diary \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-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}'
```
## Структура проекта
@@ -199,23 +245,37 @@ curl -X PUT http://localhost:8080/profile \
backend/
├── cmd/
│ ├── server/ # HTTP-сервер (точка входа)
── import/ # CLI для импорта и перевода данных (Cobra)
── 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`.