Files
food-ai/backend
dbastrikin 6af7d2fade fix: change products.canonical_name to TEXT, trim whitespace on import
VARCHAR(255) was too narrow for some OpenFoodFacts product names.
Switching to TEXT removes the length constraint entirely.
Also trim whitespace from resolved canonical names during import.

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

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

Алгоритм:

  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 ставит задание в очередь и сразу возвращает:

{"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.