- backend/.env.example: add GEMINI_API_KEY and PEXELS_API_KEY placeholders - backend/Makefile: add test-integration to PHONY targets - backend/README.md: document external API keys, import/translate commands - docs/Design.md, docs/Tech.md: reflect Iteration 1 implementation and future plans Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
52 KiB
FoodAI — Техническая архитектура
1. Стек технологий
| Компонент | Технология | Обоснование |
|---|---|---|
| Backend | Go | Высокая производительность, встроенная конкурентность (горутины для очередей), строгая типизация, простой деплой (один бинарник) |
| База данных | PostgreSQL | Надёжная реляционная БД, JSONB для гибких структур (нутриенты, шаги рецептов), полнотекстовый поиск, зрелая экосистема |
| Клиент | Flutter (Android, iOS, Web) | Единая кодовая база на три платформы, нативная производительность на мобильных, зрелая экосистема виджетов |
| Авторизация | Firebase Auth | Бесплатно до 50K MAU, email + Google + Apple из коробки, официальный Go SDK, отличная интеграция с Flutter |
| AI (vision + текст) | Google Gemini 2.5 Flash | Единая модель для распознавания фото (чеки, продукты, блюда), генерации рецептов, меню и замены ингредиентов. Free tier для разработки |
| Изображения | Pexels API | Фото к рецептам. Бесплатно до 20K req/мес, коммерческое использование без атрибуции |
| Хранение файлов | S3-совместимое (MinIO / Cloud Storage) | Фото блюд от пользователей, загружаемые для распознавания |
2. Архитектура системы
┌──────────────────────────────────────────────────────────┐
│ Flutter Client │
│ (Android / iOS / Web) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Firebase │ │ REST │ │ File │ │
│ │ Auth SDK │ │ Client │ │ Upload │ │
│ └─────┬────┘ └─────┬────┘ └─────┬────┘ │
└────────┼─────────────┼─────────────┼─────────────────────┘
│ │ │
│ idToken │ JWT │ multipart
▼ ▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Go Backend │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Auth │ │ API │ │ AI Queue │ │
│ │ Middleware │ │ Handlers │ │ Manager │ │
│ │ │ │ │ │ │ │
│ │ Firebase │ │ /products │ │ Priority │ │
│ │ Token │ │ /recipes │ │ Queues: │ │
│ │ Verify │ │ /menu │ │ - paid (fast) │ │
│ │ → JWT │ │ /diary │ │ - free (slow) │ │
│ └──────┬──────┘ │ /ai │ │ │ │
│ │ │ /shopping │ │ Rate Limiter │ │
│ │ └──────┬───────┘ │ Budget Guard │ │
│ │ │ └───────┬────────┘ │
│ │ │ │ │
│ ┌──────┴────────────────┴──────────────────┴────────┐ │
│ │ Service Layer │ │
│ │ │ │
│ │ ┌──────────┐ ┌───────────┐ ┌──────────────────┐ │ │
│ │ │ Product │ │ Recipe │ │ AI Service │ │ │
│ │ │ Service │ │ Service │ │ (interface) │ │ │
│ │ └────┬─────┘ └─────┬─────┘ └────┬─────────────┘ │ │
│ └───────┼─────────────┼────────────┼────────────────┘ │
│ │ │ │ │
└──────────┼─────────────┼────────────┼────────────────────┘
│ │ │
▼ │ ▼
┌──────────────────┐ │ ┌──────────────────┐
│ │ │ │ │
│ PostgreSQL │ │ │ Gemini API │
│ │ │ │ (Flash / │
│ - users │ │ │ Flash-Lite) │
│ - products │ │ │ │
│ - recipes │ │ └──────────────────┘
│ - menu_plans │ │
│ - meal_diary │ ▼
│ - reviews │ ┌──────────────────┐
│ - ai_tasks │ │ │
│ │ │ Pexels API │
└──────────────────┘ │ (фото к рец.) │
│ │
└──────────────────┘
3. Авторизация
Поток авторизации
Flutter Firebase Go Backend
│ │ │
│ 1. signInWithGoogle() │ │
│ ──────────────────────► │ │
│ │ │
│ 2. Firebase idToken │ │
│ ◄────────────────────── │ │
│ │ │
│ 3. POST /auth/login │ │
│ {firebase_token} │ │
│ ─────────────────────────┼─────────────────► │
│ │ │
│ │ 4. VerifyIDToken() │
│ │ ◄─────────────── │
│ │ ───────────────► │
│ │ (uid, email) │
│ │ │
│ │ 5. Upsert user │
│ │ в PostgreSQL │
│ │ │
│ 6. {jwt, refresh_token, │ │
│ user} │ │
│ ◄────────────────────────┼────────────────── │
│ │ │
│ 7. Сохранить JWT │ │
│ в Secure Storage │ │
Детали
- Firebase Auth обрабатывает все провайдеры (email/password, Google, Apple) на стороне клиента.
- Go backend получает Firebase
idToken, верифицирует его через Firebase Admin SDK (firebase.google.com/go/v4/auth), извлекаетuid,email,name. - Backend выдаёт собственный JWT (подписанный секретом сервера) с
user_id,role(free/paid),exp. Это позволяет не зависеть от Firebase при каждом API-запросе. - Refresh token хранится в БД, ротируется при каждом использовании.
- Apple Sign-In обязателен на iOS при наличии любого стороннего входа (политика App Store). Firebase Auth обрабатывает Apple прозрачно.
- Flutter-пакеты:
firebase_auth,google_sign_in,sign_in_with_apple.
4. AI-подсистема (ядро приложения)
Абстракция
AI-провайдер скрыт за интерфейсами. Это позволяет заменить Gemini на OpenAI или специализированный API без изменения бизнес-логики.
┌─────────────────────────────────────────┐
│ AI Service (interface) │
│ │
│ FoodRecognizer │
│ ├── RecognizeReceipt(image) → []Item │
│ ├── RecognizeProducts(image) → []Item │
│ └── RecognizeDish(image) → DishInfo │
│ │
│ RecipeGenerator │
│ ├── GenerateRecipes(req) → []Recipe │
│ └── SuggestSubstitutions(req) → []Sub │
│ │
│ MenuPlanner │
│ └── GenerateMenu(req) → MenuPlan │
│ │
│ NutritionEstimator │
│ └── EstimateNutrition(dish) → KBJU │
│ │
└──────────────┬──────────────────────────┘
│
┌────────┴────────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ Gemini │ │ OpenAI │
│ Adapter │ │ Adapter │
│ (active) │ │ (резерв) │
└───────────┘ └───────────┘
AI-задачи: детали
4.1. Распознавание чека
- Модель: Gemini 2.5 Flash (vision)
- Вход: фото чека (JPEG/PNG)
- Промпт: системная инструкция + фото → structured JSON
- Выход:
{
"items": [
{
"name": "Молоко 2.5%",
"quantity": 1,
"unit": "л",
"category": "dairy",
"price": 49.0,
"confidence": 0.95
}
],
"unrecognized": [
{ "raw_text": "ТОВ АРТИК 1ШТ", "price": 89.0 }
]
}
- Стратегия промпта: «Ты — OCR-система для чеков из продуктовых магазинов. Извлеки список продуктов. Для каждого определи название, количество, единицу измерения, категорию. Если не можешь распознать позицию — добавь в unrecognized с оригинальным текстом. Ответ строго в JSON.»
- Fallback: если confidence < 0.5 — элемент идёт в
unrecognized.
4.2. Распознавание продуктов (фото)
- Модель: Gemini 2.5 Flash (vision)
- Вход: фото продуктов (холодильник, стол)
- Выход: аналогичная структура (без цены), но с приблизительным весом/количеством
- Нюанс: поддержка нескольких фото — результаты объединяются на бэкенде (дедупликация по названию + суммирование количеств).
4.3. Распознавание блюда
- Модель: Gemini 2.5 Flash (vision)
- Вход: фото готового блюда
- Выход:
{
"dish_name": "Паста Карбонара",
"weight_grams": 450,
"calories": 580,
"protein": 24,
"fat": 28,
"carbs": 56,
"confidence": 0.85,
"similar_dishes": ["Паста с беконом", "Спагетти алла Грича"]
}
- Нюанс:
similar_dishesиспользуется для поиска похожих рецептов в нашей БД (по названию, fuzzy search).
4.4. Генерация рецептов / подбор меню
- Модель: Gemini 2.5 Flash-Lite (текст) — дешевле, vision не нужен
- Ключевой принцип: Gemini генерирует рецепты с нуля на основе контекста пользователя. Статическая база рецептов не нужна.
1. Backend собирает контекст: продукты пользователя (с учётом сроков хранения),
профиль (цель, КБЖУ), предпочтения кухни, диетические ограничения
2. Gemini генерирует полные рецепты: название, ингредиенты с граммовками,
шаги, КБЖУ на порцию, image_query для Pexels
3. Backend параллельно запрашивает фото из Pexels по image_query
4. Результат возвращается клиенту; при сохранении → пишется в saved_recipes
- Промпт для рекомендаций:
Ты — диетолог-повар. Предложи 5 рецептов на русском языке.
Профиль:
- Цель: похудение, 1800 ккал/день
- Ограничения: без орехов
- Предпочтения: русская, азиатская кухня
Доступные продукты (приоритет — скоро истекают):
- Куриное филе 500г (истекает завтра ⚠)
- Морковь 3 шт · Рис 400г · Яйца 4 шт
Верни ТОЛЬКО валидный JSON без markdown. Для каждого рецепта:
title, description, cuisine, difficulty, prep_time_min, cook_time_min,
servings, image_query (EN), ingredients, steps, tags, nutrition_per_serving.
- Выход: массив рецептов с
image_query— бэкенд дозапрашивает Pexels и возвращает готовый объект сimage_url.
4.5. AI-генерация персональных рецептов
Отдельный кейс — когда в БД нет подходящего рецепта из имеющихся продуктов:
- Модель: Gemini 2.5 Flash-Lite
- Промпт: «Из продуктов [список] предложи рецепт. Формат: JSON с названием, ингредиентами (с граммовками), шагами, калорийностью, БЖУ.»
- Результат: сохраняется в БД как
source = 'ai_generated', помечается в UI как «AI-рецепт». - Валидация нутриентов: значения КБЖУ от Gemini считаются приблизительными и помечаются «≈» в UI. Точная верификация через USDA FoodData Central запланирована в будущем (см. TODO.md).
4.6. Замена ингредиентов
- Модель: Gemini 2.5 Flash-Lite
- Вход: ингредиент + доступные продукты
- Выход:
[{ "original": "пекорино", "substitute": "пармезан", "ratio": "1:1", "note": "Менее острый вкус" }] - Кеширование: результаты замен кешируются в БД (таблица
ingredient_substitutions), чтобы не запрашивать AI повторно для одинаковых пар.
5. Маппинг ингредиентов (нормализация продуктов пользователя)
Проблема
Пользователи вводят продукты свободным текстом: "куриное филе", "куриная грудка", "курица без кости". Это одно и то же. Нужен слой нормализации — чтобы при генерации рекомендаций Gemini понимал, что у пользователя есть, и чтобы не дублировались продукты.
Решение: таблица ingredient_mappings
Каноническая таблица с алиасами на русском и английском. Заполняется по мере работы приложения через Gemini (рантайм-дополнение).
┌───────────────────────────────────────────────────────┐
│ ingredient_mappings │
│───────────────────────────────────────────────────────│
│ id UUID │
│ canonical_name "chicken_breast" │
│ canonical_name_ru "куриная грудка" │
│ aliases (JSONB) ["куриное филе", "куриная грудка", │
│ "грудка курицы", "chicken breast"]│
│ category "meat" │
│ default_unit "g" │
│ calories_per_100g 165 (≈ от Gemini) │
│ protein_per_100g 31 │
│ fat_per_100g 3.6 │
│ storage_days 3 │
└───────────────────────────────────────────────────────┘
Как работает связка
Продукт юзера ingredient_mappings Промпт для Gemini
────────────── ─────────────────── ──────────────────
"Куриное филе" │ canonical_name_ru:
│ │ "куриная грудка"
│ Fuzzy search │ │
└────по aliases ────► canonical_name ──────────►│
"chicken_breast" Gemini понимает
что это за продукт
Потоки по сценариям
Сценарий 1: Добавление продукта (чек/фото → запасы)
1. Gemini возвращает: "Куриное филе, 500г"
2. Backend: fuzzy search по ingredient_mappings.aliases
SELECT * FROM ingredient_mappings
WHERE aliases @> '"куриное филе"'::jsonb
OR similarity(canonical_name, 'куриное филе') > 0.3
3. Найдено → product.mapping_id = ingredient_mappings.id
Автоподставляются: category, default_unit, storage_days, нутриенты
4. Не найдено → разовый запрос к Gemini:
"К какому каноническому ингредиенту относится 'филе индейки'?
Ответь JSON: { canonical_name, category, calories_per_100g, ... }"
Результат сохраняется в ingredient_mappings (новая строка)
Следующий юзер с таким же продуктом — AI не нужен
Сценарий 2: Проверка "есть ли ингредиент рецепта в запасах"
1. Рецепт "Паста Карбонара" содержит ингредиент: canonical_name = "spaghetti"
2. products: ищем WHERE mapping_id IN (
SELECT id FROM ingredient_mappings WHERE canonical_name = 'spaghetti'
)
3. Найдено в products → отметка ✅ (есть в запасах)
Не найдено → отметка ❌ (нет) или 🔄 (есть замена через ingredient_substitutions)
Сценарий 3: Рекомендации рецептов "из моих продуктов"
1. Продукты юзера → через mapping_id → набор canonical_names_ru
["куриная грудка", "рис", "морковь", "лук"]
2. Backend формирует промпт для Gemini:
- профиль пользователя (цель, КБЖУ-норма, ограничения)
- список продуктов с количеством и сроками
- приоритет: продукты, истекающие скоро
3. Gemini генерирует 5 рецептов с нуля, используя имеющиеся продукты.
Каждый рецепт содержит image_query (EN) для запроса Pexels.
4. Backend параллельно запрашивает Pexels по image_query.
5. Результат: персонализированные рецепты с фото.
Статическая база рецептов не нужна.
Сценарий 4: Распознавание блюда (фото → калории)
1. Gemini определяет: "Паста Карбонара"
2. Backend: full-text search по recipes.title
SELECT * FROM recipes
WHERE to_tsvector('russian', title) @@ plainto_tsquery('russian', 'Паста Карбонара')
ORDER BY review_count DESC LIMIT 5
3. Найдено → привязка к рецепту (точные нутриенты из БД)
Не найдено → используются нутриенты от Gemini (помечены "≈ приблизительно")
Сценарий 5: Генерация меню
1. Backend собирает контекст:
- профиль (цель, КБЖУ-норма, кухни, ограничения)
- продукты пользователя (canonical_names, количество, сроки)
- период меню (например, 7 дней × 3 приёма пищи)
2. Gemini генерирует полное меню с нуля:
- 7 дней, 3 приёма пищи в день
- каждый слот: полный рецепт (ингредиенты, шаги, КБЖУ, image_query)
- баланс КБЖУ в рамках дневной нормы
- приоритет продуктам с истекающим сроком
3. Backend параллельно запрашивает Pexels для каждого рецепта.
4. Меню сохраняется в menu_plans + menu_items.
Рецепты из меню — в saved_recipes (source = 'ai').
→ Gemini генерирует контент полностью. Никакой статической базы не нужно.
Наполнение таблицы маппингов
| Этап | Источник | Объём |
|---|---|---|
| Начальный seed | Ручное составление топ-200 базовых ингредиентов | Aliases на русском и английском |
| Рантайм-дополнение | Gemini (при нераспознанном продукте) | По мере роста юзеров |
| Пользовательская обратная связь | Юзер корректирует продукт на экране редактирования | Новые aliases |
Со временем маппинг растёт, и AI для распознавания ингредиентов вызывается всё реже.
Сводная таблица: где Gemini, а где нет
| Задача | Gemini? | Как стыкуется с БД |
|---|---|---|
| OCR чека → продукты | Да (vision) | Fuzzy match по ingredient_mappings.aliases |
| Фото продуктов → список | Да (vision) | То же |
| Фото блюда → калории | Да (vision) | Результат логируется в ai_tasks |
| Рекомендации рецептов из продуктов | Да (текст) | Продукты передаются в промпт; результат → saved_recipes |
| Генерация меню на неделю | Да (текст) | Генерирует с нуля; сохраняется в menu_plans + menu_items |
| Замена ингредиентов | Да (текст) | Кеш в ingredient_substitutions |
| Нераспознанный ингредиент | Да (текст, разово) | Результат сохраняется в ingredient_mappings |
6. Система очередей AI-запросов
Проблема
Gemini API имеет ограничения по RPM (requests per minute) и стоит денег. Нужно:
- Не допустить перерасход бюджета
- Дать приоритет платным пользователям
- Не блокировать систему при пиковых нагрузках
Архитектура
Входящий AI-запрос
│
▼
┌─────────────────────┐
│ Rate Limiter │
│ (per-user) │
│ │
│ Free: 20 req/час │
│ Paid: 100 req/час │
└──────────┬──────────┘
│
┌──────┴──────┐
│ │
Paid user Free user
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Paid Queue │ │ Free Queue │
│ │ │ │
│ N воркеров │ │ 1 воркер │
│ (быстро) │ │ (медленно) │
└──────┬──────┘ └──────┬──────┘
│ │
└───────┬───────┘
│
▼
┌─────────────────────┐
│ Budget Guard │
│ │
│ Daily limit: $X │
│ Current: $Y │
│ │
│ Y >= X? → REJECT │
│ Y >= 0.8X? → WARN │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Gemini API Call │
│ │
│ + Track cost │
│ + Log to ai_tasks │
└─────────────────────┘
Компоненты
Rate Limiter (per-user)
- Алгоритм: token bucket (горутина + каналы в Go).
- Лимиты хранятся в конфигурации, разделены по тарифу (free/paid).
- При превышении: HTTP 429 + заголовок
Retry-After. - Клиент показывает: «Слишком много запросов. Попробуйте через N секунд.»
Priority Queues
- Paid Queue: N воркеров (горутин), обрабатывающих запросы параллельно. N зависит от лимита RPM Gemini API (например, при лимите 60 RPM и двух очередях — 50 RPM на paid, 10 RPM на free).
- Free Queue: 1 воркер, обрабатывает запросы последовательно. Пользователи free-тарифа ждут дольше, но результат получают.
- Реализация:
chan AITaskв Go с горутинами-воркерами. Без внешних брокеров сообщений на данном этапе. - При масштабировании: миграция на Redis Streams или NATS.
Budget Guard
- Daily budget cap: максимальная сумма затрат на Gemini API в день (например, $50).
- Учёт затрат: каждый запрос логируется с оценочной стоимостью (input_tokens × price + output_tokens × price). Gemini API возвращает
usage_metadataс точными токенами. - Пороги:
- 80% бюджета: предупреждение в логи, приоритет только paid-очереди.
- 100% бюджета: free-очередь останавливается, paid продолжает (из резерва).
- 120% бюджета (абсолютный лимит): все AI-запросы отклоняются.
- Graceful degradation: при отклонении запроса клиент показывает: «AI-распознавание временно недоступно. Вы можете добавить продукты вручную.»
Таблица ai_tasks (лог)
| Поле | Тип | Описание |
|---|---|---|
| id | UUID | |
| user_id | UUID | FK → users |
| task_type | ENUM | receipt_ocr, product_recognition, dish_recognition, recipe_generation, menu_planning, substitution |
| status | ENUM | queued, processing, completed, failed, rejected |
| priority | ENUM | free, paid |
| input_tokens | INT | Токены на вход (от API) |
| output_tokens | INT | Токены на выход (от API) |
| estimated_cost | DECIMAL | Оценочная стоимость в $ |
| queue_time_ms | INT | Время ожидания в очереди |
| process_time_ms | INT | Время обработки |
| created_at | TIMESTAMP | |
| completed_at | TIMESTAMP |
Используется для: аналитики затрат, мониторинга, оптимизации промптов.
7. Рецепты и контент
Источники рецептов
Статической базы рецептов нет. Все рецепты генерируются Gemini on-demand и сохраняются пользователями.
┌─────────────────────────────────────────────────┐
│ Recipe Service │
│ │
│ ┌──────────────────────┐ ┌──────────────────┐ │
│ │ AI-generated │ │ User Recipes │ │
│ │ (Gemini) │ │ (будущее) │ │
│ │ │ │ │ │
│ │ source: 'ai' │ │ source: 'user' │ │
│ └──────────┬───────────┘ └────────┬─────────┘ │
│ │ │ │
│ └──────────┬────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ saved_recipes │ │
│ │ (per-user) │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────┘
Принцип работы с рецептами
- Генерация: Gemini создаёт рецепты с нуля на основе контекста пользователя (продукты, профиль, цели).
- Фото: Pexels API по
image_query(EN), которую Gemini включает в ответ. - КБЖУ: приблизительные значения от Gemini, помечаются «≈» в UI.
- Сохранение: пользователь тапает ❤ → рецепт сохраняется в
saved_recipes. До сохранения нигде не хранится. - Отсутствие глобального каталога: каждый пользователь видит только свои сохранённые рецепты и свежие рекомендации. Полноценный каталог с поиском — в TODO.md (требует постоянной базы).
Согласованность рецептов
- Все данные рецепта (ингредиенты, шаги, КБЖУ, image_url) приходят от Gemini+Pexels в одном запросе.
- Сохранённый рецепт хранится целиком в
saved_recipesкак JSONB — нет риска рассинхронизации. - КБЖУ помечены как приблизительные — пользователь понимает точность данных.
8. Доменные сущности (схема БД)
┌──────────────┐ ┌──────────────────┐
│ users │ │ products │
│──────────────│ │──────────────────│
│ id │◄──┐ │ id │
│ firebase_uid │ │ │ user_id (FK) │──► users
│ email │ │ │ mapping_id (FK) │──► ingredient_mappings
│ name │ │ │ name │
│ avatar_url │ │ │ quantity │
│ height_cm │ │ │ unit │
│ weight_kg │ │ │ category │
│ age │ │ │ storage_days │
│ gender │ │ │ added_at │
│ activity │ │ │ expires_at │
│ goal │ │ │ (computed) │
│ │ │ └──────────────────┘
│ plan (free/ │ │
│ paid) │ │ ┌──────────────────┐
│ preferences │ │ │ recipes │
│ (JSONB) │ │ │──────────────────│
│ created_at │ │ │ id │
└──────────────┘ │ │ source │
│ │ title │
│ │ description │
│ │ cuisine │
│ │ difficulty │
│ │ prep_time_min │
│ │ calories │
│ │ protein │
│ │ fat │
│ │ carbs │
│ │ servings │
│ │ image_url │
│ │ ingredients │
│ │ (JSONB) │
│ │ steps (JSONB) │
│ │ tags (JSONB) │
│ │ avg_rating │
│ │ review_count │
│ │ created_by │──► users (NULL для ai-generated)
│ └──────────────────┘
│
┌──────────────────┤ ┌──────────────────┐
│ menu_plans │ │ menu_items │
│──────────────────│ │──────────────────│
│ id │ │ id │
│ user_id (FK) ───┘ │ menu_plan_id(FK) │──► menu_plans
│ week_start │ │ day_of_week │
│ created_at │ │ meal_type │
│ template_name │ │ recipe_id (FK) │──► recipes
│ (NULL если не │ │ servings │
│ шаблон) │ └──────────────────┘
└──────────────────┘
┌──────────────────┐
│ meal_diary │
│──────────────────│
│ id │
│ user_id (FK) │──► users
│ date │
│ meal_type │
│ recipe_id (FK) │──► recipes (NULL для ручного ввода)
│ name │
│ portions │
│ calories │
│ protein │
│ fat │
│ carbs │
│ source │
│ (menu/photo/ │
│ manual/recipe) │
│ created_at │
└──────────────────┘
┌──────────────────┐ ┌──────────────────┐
│ reviews │ │ shopping_lists │
│──────────────────│ │──────────────────│
│ id │ │ id │
│ user_id (FK) │──►│ user_id (FK) │──► users
│ recipe_id (FK) │──►│ menu_plan_id(FK) │──► menu_plans
│ rating (1-5) │ │ items (JSONB) │
│ text │ │ created_at │
│ photo_url │ └──────────────────┘
│ created_at │
└──────────────────┘ ┌──────────────────┐
│ water_tracker │
│──────────────────│
│ id │
│ user_id (FK) │──► users
│ date │
│ glasses │
└──────────────────┘
┌──────────────────┐ ┌──────────────────────────┐
│ ai_tasks │ │ ingredient_substitutions │
│──────────────────│ │──────────────────────────│
│ (см. раздел 6) │ │ id │
└──────────────────┘ │ original_mapping_id (FK) │──► ingredient_mappings
│ substitute_mapping_id(FK)│──► ingredient_mappings
│ ratio │
│ note │
│ created_at │
└──────────────────────────┘
┌────────────────────────────┐
│ ingredient_mappings │
│────────────────────────────│
│ id │
│ canonical_name │
│ canonical_name_ru │
│ aliases (JSONB) │
│ category │
│ default_unit │
│ calories_per_100g │
│ protein_per_100g │
│ fat_per_100g │
│ carbs_per_100g │
│ storage_days │
│ created_at │
└────────────────────────────┘
Ключевые решения по схеме
products.mapping_id— FK наingredient_mappings. Связывает продукт пользователя с каноническим ингредиентом. Через эту связь определяется наличие ингредиентов рецепта в запасах. Может быть NULL (если продукт не удалось сопоставить).ingredient_mappings— каноническая таблица ингредиентов.canonical_name_ru— русское название.aliases(JSONB) содержит все варианты написания на разных языках. Нутриенты на 100г используются для пересчёта калорийности при необходимости.storage_days— дефолтный срок хранения для категории. Наполняется вручную (seed) и автоматически через Gemini при нераспознанных продуктах.ingredient_substitutions— кеш замен ингредиентов. Ссылается наingredient_mappingsпо обоим ингредиентам (оригинал и замена). Один раз определённая AI-замена переиспользуется для всех пользователей.products.storage_days— период хранения после покупки (не фиксированная дата).expires_atвычисляется какadded_at + storage_days. При отображении: «осталось X дней». Дефолт берётся изingredient_mappings.storage_daysпри привязке.products.unit— ENUM: g, kg, ml, l, pcs, bunch, pack. Фронтенд отображает локализованные названия. Дефолт берётся изingredient_mappings.default_unit.recipes.ingredients— JSONB массив:[{ "name": "...", "amount": 200, "unit": "g", "optional": false }]. JSONB позволяет гибко хранить без нормализации, при этом поддерживая поиск (@>оператор).recipes.steps— JSONB массив:[{ "order": 1, "title": "...", "description": "...", "image_url": "...", "timer_seconds": null }].timer_secondsне null → на шаге отображается таймер.recipes.source— ENUM: ai, user. Определяет происхождение рецепта.meal_diary.source— откуда записано: из меню, через фото, вручную, из рецепта.shopping_lists.items— JSONB:[{ "name": "...", "amount": 1, "unit": "l", "category": "dairy", "checked": false, "manual": false }].
9. API-дизайн (ключевые эндпоинты)
Авторизация
| Метод | Путь | Описание |
|---|---|---|
| POST | /auth/login |
Firebase token → JWT |
| POST | /auth/refresh |
Обновить JWT |
| POST | /auth/logout |
Инвалидировать refresh token |
Продукты
| Метод | Путь | Описание |
|---|---|---|
| GET | /products |
Список продуктов (фильтры: category, expiring) |
| POST | /products |
Добавить продукт вручную |
| POST | /products/batch |
Массовое добавление (после распознавания) |
| PUT | /products/{id} |
Обновить (вес, количество, срок) |
| PATCH | /products/{id}/consume |
Частичное использование |
| DELETE | /products/{id} |
Удалить |
| DELETE | /products |
Очистить все (с подтверждением в query param) |
| GET | /products/storage-defaults |
Дефолтные сроки хранения по категориям |
| PUT | /products/storage-defaults |
Обновить дефолтные сроки |
AI-операции
| Метод | Путь | Описание |
|---|---|---|
| POST | /ai/recognize-receipt |
Фото чека → список продуктов |
| POST | /ai/recognize-products |
Фото продуктов → список |
| POST | /ai/recognize-dish |
Фото блюда → калории, БЖУ |
| POST | /ai/suggest-recipes |
Подбор рецептов из продуктов |
| POST | /ai/generate-menu |
Генерация меню на период |
| POST | /ai/substitute |
Замена ингредиента |
| GET | /ai/tasks/{id} |
Статус AI-задачи (для polling) |
Все /ai/* эндпоинты возвращают { task_id } (HTTP 202 Accepted). Клиент получает результат через polling /ai/tasks/{id} или через WebSocket (будущее улучшение). Это позволяет обрабатывать запросы асинхронно через очереди.
Рекомендации
| Метод | Путь | Описание |
|---|---|---|
| GET | /recommendations?count=5 |
Персональные рекомендации (Gemini + Pexels) |
Сохранённые рецепты
| Метод | Путь | Описание |
|---|---|---|
| GET | /saved-recipes |
Список сохранённых рецептов |
| POST | /saved-recipes |
Сохранить рецепт |
| GET | /saved-recipes/{id} |
Карточка сохранённого рецепта |
| DELETE | /saved-recipes/{id} |
Удалить из сохранённых |
Меню
| Метод | Путь | Описание |
|---|---|---|
| GET | /menu?week=2026-W08 |
Меню на неделю |
| PUT | /menu/items/{id} |
Обновить слот меню |
| POST | /menu/items |
Добавить блюдо в слот |
| DELETE | /menu/items/{id} |
Убрать блюдо |
| POST | /menu/templates |
Сохранить как шаблон |
| GET | /menu/templates |
Список шаблонов |
| POST | /menu/from-template/{id} |
Применить шаблон |
Дневник питания
| Метод | Путь | Описание |
|---|---|---|
| GET | /diary?date=2026-02-15 |
Записи за день |
| POST | /diary |
Добавить запись |
| PUT | /diary/{id} |
Изменить (порция, и т.д.) |
| DELETE | /diary/{id} |
Удалить запись |
Список покупок
| Метод | Путь | Описание |
|---|---|---|
| GET | /shopping-list |
Текущий список |
| POST | /shopping-list/generate |
Сгенерировать из меню |
| PUT | /shopping-list/items/{index} |
Обновить позицию |
| PATCH | /shopping-list/items/{index}/check |
Отметить купленным |
| POST | /shopping-list/items |
Добавить вручную |
Статистика
| Метод | Путь | Описание |
|---|---|---|
| GET | /stats?period=week |
Статистика за период |
| GET | /stats/water?date=2026-02-15 |
Трекер воды |
| PUT | /stats/water |
Обновить стаканы |
Профиль
| Метод | Путь | Описание |
|---|---|---|
| GET | /profile |
Данные профиля |
| PUT | /profile |
Обновить |
| PUT | /profile/preferences |
Обновить предпочтения кухонь |
| PUT | /profile/restrictions |
Обновить ограничения |
10. Оценка затрат
Стоимость по стадиям (в месяц)
| Компонент | 1K MAU | 10K MAU | 50K MAU |
|---|---|---|---|
| Firebase Auth | $0 | $0 | $0 (лимит 50K) |
| Gemini API | $50–150 | $500–1 500 | $2 500–7 500 |
| Pexels API | $0 | $0 | $0 (Free tier) |
| Хостинг (Cloud Run / Fly.io) | $10–20 | $50–100 | $200–500 |
| PostgreSQL (managed) | $15 | $50 | $150–300 |
| S3 (фото) | $5 | $20 | $50–100 |
| Итого | $80–190 | $620–1 670 | $2 900–8 400 |
Оптимизация затрат на AI
- Кеширование результатов: замены ингредиентов, распознавание типовых блюд.
- Batch-запросы: при генерации меню на неделю — один запрос вместо семи.
- Снижение detail для фото: Gemini поддерживает
low/highdetail. Для чековhigh, для фото продуктовlowдостаточно. - Мониторинг: дашборд затрат по
ai_tasks— какие задачи дорогие, где можно оптимизировать промпты.