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>
16 KiB
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 (
go install github.com/pressly/goose/v3/cmd/goose@latest) - Файл
firebase-credentials.json(сервисный аккаунт Firebase)
Быстрый старт
1. Переменные окружения
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, сервер и оба воркера:
OPENAI_API_KEY=your-key make docker-up
3. Запуск локально (инфраструктура в Docker)
# Поднять только PostgreSQL + Kafka
make dev-infra-up
# Применить миграции
make migrate-up
# Запустить всё локально (сервер + оба воркера + инфраструктура)
make dev
Или по отдельности в разных терминалах:
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.
# Скачать дамп (~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 |
Алгоритм:
- Создаёт временную staging-таблицу без уникальных ограничений.
- Читает файл построчно (gzip-прозрачно); пропускает записи без баркода, названия или калорий.
- Дедуплицирует
canonical_nameвнутри батча. - Загружает батч через
COPY(без ошибок на дублях). - Обнуляет barcodes, уже занятые другой записью в каталоге.
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 ставит задание в очередь и сразу возвращает:
{"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 партиция) — бесплатные пользователи
Примеры запросов
Логин:
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"firebase_token": "<FIREBASE_ID_TOKEN>"}'
Поиск продукта:
curl "http://localhost:8080/products/search?q=butter&limit=5" \
-H "Authorization: Bearer <ACCESS_TOKEN>"
Поиск по баркоду:
curl http://localhost:8080/products/barcode/3017620422003 \
-H "Authorization: Bearer <ACCESS_TOKEN>"
Добавить запись в дневник:
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 таблицах.
Тесты
# Unit-тесты (с race detector)
make test
# Интеграционные тесты (PostgreSQL поднимается через testcontainers)
make test-integration
Покрытые пакеты: auth, diary, middleware, recognition, recipe, savedrecipe, user, userproduct, locale, menu.