317 lines
16 KiB
Markdown
317 lines
16 KiB
Markdown
# FoodAI Backend
|
||
|
||
Go REST API с авторизацией через Firebase и JWT, асинхронным распознаванием блюд через Gemini AI, и каталогом продуктов питания на базе OpenFoodFacts.
|
||
|
||
## Стек
|
||
|
||
- **Go 1.25.5** — язык
|
||
- **chi v5** — HTTP-роутер
|
||
- **pgx v5 / pgxpool** — драйвер PostgreSQL
|
||
- **goose v3** — миграции
|
||
- **golang-jwt/v5** — JWT
|
||
- **Firebase Admin SDK** — верификация токенов
|
||
- **franz-go** — Kafka-продюсер и консьюмер (pure Go)
|
||
- **watermill + watermill-kafka** — маршрутизация событий (воркер)
|
||
- **envconfig** — конфигурация через переменные окружения
|
||
- **testcontainers-go** — интеграционные тесты с реальной БД
|
||
- **generative-ai-go / OpenAI API** — распознавание блюд, генерация рецептов и меню
|
||
- **Pexels API** — изображения для рецептов
|
||
|
||
## Требования
|
||
|
||
- 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)
|
||
|
||
## Быстрый старт
|
||
|
||
### 1. Переменные окружения
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
```
|
||
|
||
Отредактируйте `.env`:
|
||
|
||
| Переменная | Описание | По умолчанию |
|
||
|---|---|---|
|
||
| `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` | TTL access-токена | `15m` |
|
||
| `JWT_REFRESH_DURATION` | TTL refresh-токена | `720h` |
|
||
| `PORT` | Порт HTTP-сервера | `8080` |
|
||
| `ALLOWED_ORIGINS` | CORS-разрешённые источники | `http://localhost:3000` |
|
||
| `KAFKA_BROKERS` | Адреса Kafka-брокеров | `localhost:9093` (dev), `kafka:9092` (Docker) |
|
||
| `OPENAI_API_KEY` | API-ключ Gemini/OpenAI (распознавание, генерация) | — |
|
||
| `PEXELS_API_KEY` | API-ключ Pexels (изображения для рецептов) | — |
|
||
|
||
### 2. Запуск через Docker Compose
|
||
|
||
Поднимает PostgreSQL, Kafka, сервер и оба воркера:
|
||
|
||
```bash
|
||
OPENAI_API_KEY=your-key make docker-up
|
||
```
|
||
|
||
### 3. Запуск локально (инфраструктура в Docker)
|
||
|
||
```bash
|
||
# Поднять только PostgreSQL + Kafka
|
||
make dev-infra-up
|
||
|
||
# Применить миграции
|
||
make migrate-up
|
||
|
||
# Запустить всё локально (сервер + оба воркера + инфраструктура)
|
||
make dev
|
||
```
|
||
|
||
Или по отдельности в разных терминалах:
|
||
|
||
```bash
|
||
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` | API-ключ Gemini/OpenAI |
|
||
| `KAFKA_BROKERS` | Адреса Kafka-брокеров |
|
||
|
||
## Команды
|
||
|
||
### Сервер и тесты
|
||
|
||
| Команда | Описание |
|
||
|---|---|
|
||
| `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` | Полный 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>` | Создать новую миграцию |
|
||
| `make import-off file=<path>` | Импортировать каталог из OpenFoodFacts JSONL/GZ |
|
||
|
||
## Импорт каталога продуктов
|
||
|
||
Каталог продуктов заполняется из дампа [OpenFoodFacts](https://world.openfoodfacts.org/data).
|
||
|
||
```bash
|
||
# Скачать дамп (~66 ГБ, ~4.4 млн записей)
|
||
# https://world.openfoodfacts.org/data/openfoodfacts-products.jsonl.gz
|
||
|
||
# Через make (DSN берётся из .env)
|
||
make import-off file=openfoodfacts-products.jsonl.gz
|
||
|
||
# Или напрямую с дополнительными флагами
|
||
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` | Статус сервера и БД |
|
||
| `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/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 партиция) — бесплатные пользователи
|
||
|
||
### Примеры запросов
|
||
|
||
**Логин:**
|
||
```bash
|
||
curl -X POST http://localhost:8080/auth/login \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"firebase_token": "<FIREBASE_ID_TOKEN>"}'
|
||
```
|
||
|
||
**Поиск продукта:**
|
||
```bash
|
||
curl "http://localhost:8080/products/search?q=butter&limit=5" \
|
||
-H "Authorization: Bearer <ACCESS_TOKEN>"
|
||
```
|
||
|
||
**Поиск по баркоду:**
|
||
```bash
|
||
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 '{"date": "2026-03-21", "meal_type": "lunch", "product_id": "...", "portion_g": 150}'
|
||
```
|
||
|
||
## Структура проекта
|
||
|
||
```
|
||
backend/
|
||
├── cmd/
|
||
│ ├── server/ # HTTP-сервер (точка входа)
|
||
│ ├── worker/ # Async-воркер распознавания блюд (Kafka)
|
||
│ └── importoff/ # Импорт каталога продуктов из OpenFoodFacts JSONL
|
||
├── internal/
|
||
│ ├── 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_initial_schema.sql # Полная схема БД
|
||
├── tests/ # Интеграционные и unit-тесты по доменам
|
||
├── .env.example
|
||
├── docker-compose.yml
|
||
├── Dockerfile
|
||
└── Makefile
|
||
```
|
||
|
||
## Схема БД
|
||
|
||
Вся схема содержится в одном файле `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` — справочники
|
||
|
||
Для каждой таблицы с пользовательским текстом используется паттерн `{table}_translations` (companion table). Базовые таблицы хранят английский canonical text, переводы — только в `_translations` таблицах.
|
||
|
||
## Тесты
|
||
|
||
```bash
|
||
# Unit-тесты (с race detector)
|
||
make test
|
||
|
||
# Интеграционные тесты (PostgreSQL поднимается через testcontainers)
|
||
make test-integration
|
||
```
|
||
|
||
Покрытые пакеты: `auth`, `diary`, `middleware`, `recognition`, `recipe`, `savedrecipe`, `user`, `userproduct`, `locale`, `menu`.
|