Files
food-ai/backend/README.md
dbastrikin 78f1c8bf76 feat: food search sheet with FTS+trgm, dish/recent endpoints, multilingual aliases
Backend:
- GET /dishes/search — hybrid FTS (english + simple) + trgm + ILIKE search
- GET /diary/recent — recently used dishes and products for the current user
- product search upgraded: FTS on canonical_name and product_aliases, ranked by GREATEST(ts_rank, similarity)
- importoff: collect product_name_ru/de/fr/... as product_aliases for multilingual search (e.g. "сникерс" → "Snickers")
- migrations: FTS + trgm indexes merged into 001_initial_schema.sql (002 removed)

Flutter:
- FoodSearchSheet: debounced search field, recently-used section, product/dish results, scan-photo and barcode chips
- DishPortionSheet: quick ½/1/1½/2 buttons + custom input
- + button in meal card now opens FoodSearchSheet instead of going directly to AI scan
- 7 new l10n keys across all 12 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 15:28:29 +02:00

321 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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> [limit=N] [batch=N] [min-scans=N]` | Импортировать каталог из 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
make import-off file=openfoodfacts-products.jsonl.gz limit=10000 min-scans=0
# Или напрямую
go run ./cmd/importoff \
-file=openfoodfacts-products.jsonl.gz \
-dsn="$DATABASE_URL" \
-limit=10000 \
-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` | Записи дневника питания за дату |
| `GET` | `/diary/recent?limit=<n>` | Последние записи дневника (блюда и продукты) |
| `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/search?q=<query>&limit=<n>` | Поиск блюд (FTS + trgm) |
| `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`.