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:
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user