Files
food-ai/docs/Tech.md
dbastrikin 24219b611e feat: implement Iteration 0 foundation (backend + Flutter client)
Backend (Go):
- Project structure with chi router, pgxpool, goose migrations
- JWT auth (access/refresh tokens) with Firebase token verification
- NoopTokenVerifier for local dev without Firebase credentials
- PostgreSQL user repository with atomic profile updates (transactions)
- Mifflin-St Jeor calorie calculation based on profile data
- REST API: POST /auth/login, /auth/refresh, /auth/logout, GET/PUT /profile, GET /health
- Middleware: auth, CORS (localhost wildcard), logging, recovery, request_id
- Unit tests (51 passing) and integration tests (testcontainers)
- Docker Compose setup with postgres healthcheck and graceful shutdown

Flutter client:
- Riverpod state management with GoRouter navigation
- Firebase Auth (email/password + Google sign-in with web popup support)
- Platform-aware API URLs (web/Android/iOS)
- Dio HTTP client with JWT auth interceptor and concurrent refresh handling
- Secure token storage
- Screens: Login, Register, Home (tabs: Menu, Recipes, Products, Profile)
- Unit tests (17 passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 13:14:58 +02:00

52 KiB
Raw Blame History

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 Лучшее соотношение цена/качество для распознавания еды (~$0.15/1M input tokens), бесплатный tier для разработки, прецедент CalCam
AI (текст) Google Gemini 2.5 Flash-Lite Самый дешёвый вариант для текстовых задач ($0.10/1M input tokens) — генерация рецептов, меню, замены ингредиентов
База рецептов Spoonacular API 365K+ рецептов с нутриентами, фото, ингредиентами, шагами. $29/мес на старте
Хранение файлов 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     │  │                  │
│                  │  │  Spoonacular     │
└──────────────────┘  │  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 не нужен
  • Ключевой принцип: AI НЕ генерирует рецепты с нуля. Вместо этого:
1. Backend выбирает из БД кандидатов-рецептов (по фильтрам: кухня, сложность, время, ингредиенты)
2. AI получает список кандидатов (ID + название + ингредиенты + калории) + контекст юзера
3. AI ранжирует, комбинирует в меню, предлагает замены
4. Backend возвращает полные рецепты по ID из БД
  • Промпт для подбора меню:
Контекст пользователя:
- Цель: 2100 ккал/день
- Ограничения: без орехов
- Предпочтения: русская, азиатская кухня
- Продукты в наличии: [список с количествами и сроками]

Доступные рецепты (ID, название, калории, основные ингредиенты):
[список из 50100 кандидатов из БД]

Задача: составь меню на 7 дней (завтрак, обед, ужин, перекус).
Приоритет: использовать продукты с истекающим сроком.
Ответ: JSON с recipe_id для каждого слота.
  • Выход: массив { day, meal_type, recipe_id } — бэкенд подтягивает полные данные рецептов из БД.

Это гарантирует согласованность: AI не придумывает рецепты, а выбирает из проверенной базы.

4.5. AI-генерация персональных рецептов

Отдельный кейс — когда в БД нет подходящего рецепта из имеющихся продуктов:

  • Модель: Gemini 2.5 Flash-Lite
  • Промпт: «Из продуктов [список] предложи рецепт. Формат: JSON с названием, ингредиентами (с граммовками), шагами, калорийностью, БЖУ.»
  • Результат: сохраняется в БД как source = 'ai_generated', помечается в UI как «AI-рецепт».
  • Валидация нутриентов: бэкенд пересчитывает калории/БЖУ по справочнику (USDA/Spoonacular nutrition data) и корректирует, если AI ошибся более чем на 20%.

4.6. Замена ингредиентов

  • Модель: Gemini 2.5 Flash-Lite
  • Вход: ингредиент + доступные продукты
  • Выход: [{ "original": "пекорино", "substitute": "пармезан", "ratio": "1:1", "note": "Менее острый вкус" }]
  • Кеширование: результаты замен кешируются в БД (таблица ingredient_substitutions), чтобы не запрашивать AI повторно для одинаковых пар.

5. Маппинг ингредиентов (связь AI ↔ БД рецептов)

Проблема

Gemini оперирует свободным текстом ("куриное филе", "помидоры черри"), а Spoonacular — структурированными ID ингредиентов. Продукты пользователя — тоже свободный текст. Нужен слой, который связывает все три мира.

Решение: таблица ingredient_mappings

Каноническая таблица ингредиентов с алиасами на разных языках и привязкой к Spoonacular ID.

┌───────────────────────────────────────────────────────┐
│                ingredient_mappings                     │
│───────────────────────────────────────────────────────│
│ id                  UUID                               │
│ canonical_name      "chicken_breast"                   │
│ spoonacular_id      1015062                            │
│ aliases (JSONB)     ["куриное филе", "куриная грудка", │
│                      "филе курицы", "chicken breast",  │
│                      "chicken fillet"]                 │
│ category            "meat"                             │
│ default_unit        "g"                                │
│ calories_per_100g   165                                │
│ protein_per_100g    31                                 │
│ fat_per_100g        3.6                                │
│ carbs_per_100g      0                                  │
│ storage_days        3                                  │
└───────────────────────────────────────────────────────┘

Как работает связка

Продукт юзера            ingredient_mappings         Рецепт из Spoonacular
──────────────            ───────────────────         ─────────────────────

"Куриное филе"                    │               Ингредиент рецепта:
      │                           │               spoonacular_id: 1015062
      │    Fuzzy search           │                        │
      └────по aliases ───►  canonical_name:  ◄─── по spoonacular_id
                            "chicken_breast"
                                  │
                             MATCH: оба
                             ссылаются на
                             одну сущность

Потоки по сценариям

Сценарий 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. Рецепт "Паста Карбонара" имеет ингредиент: spoonacular_id = 1015062
2. ingredient_mappings: spoonacular_id 1015062 → canonical_name "chicken_breast"
3. products: mapping_id → ingredient_mappings.id WHERE canonical_name = "chicken_breast"
4. Найдено в products → отметка ✅ (есть в запасах)
   Не найдено → отметка ❌ (нет) или 🔄 (есть замена)

Сценарий 3: Поиск рецептов "из моих продуктов"

1. Продукты юзера → через mapping_id → набор canonical_names
   ["chicken_breast", "rice_white", "carrot", "onion"]

2. SQL-запрос по рецептам:
   SELECT r.*,
     (SELECT count(*) FROM jsonb_array_elements(r.ingredients) i
      WHERE i->>'mapping_id' IN (выбранные mapping_id)) as matched,
     jsonb_array_length(r.ingredients) as total
   FROM recipes r
   ORDER BY matched::float / total DESC

3. Если в локальной БД мало результатов — дозапрос Spoonacular:
   GET /recipes/findByIngredients?ingredients=chicken,rice,carrot,onion
   Новые рецепты сохраняются в нашу БД

4. Gemini НЕ участвует — это чистый SQL + Spoonacular

Сценарий 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 отбирает кандидатов из БД (SQL: фильтры + наличие ингредиентов)
2. Формирует промпт с recipe_id:
   "ID:42 Борщ (550ккал, ингр: свёкла✅ картофель✅ говядина✅ лук✅)
    ID:87 Том Ям (320ккал, ингр: креветки❌ лемонграсс❌ кокос.молоко❌)"
3. Gemini ранжирует и возвращает recipe_id
4. Backend подгружает полные данные по ID

→ Gemini работает ТОЛЬКО с ID из нашей БД
→ Никакой рассинхронизации

Наполнение таблицы маппингов

Этап Источник Объём
Начальный импорт Spoonacular Ingredient API ~1 000 базовых ингредиентов
Ручная локализация Перевод топ-200 ингредиентов Aliases на русском
Batch-перевод Gemini Flash-Lite (оффлайн) Остальные aliases
Рантайм-дополнение Gemini (при нераспознанном продукте) По мере роста юзеров
Пользовательская обратная связь Юзер корректирует продукт на экране редактирования Новые aliases

Со временем маппинг растёт, и AI для распознавания ингредиентов вызывается всё реже.

Сводная таблица: где Gemini, а где нет

Задача Gemini? Как стыкуется с БД
OCR чека → продукты Да (vision) Fuzzy match по ingredient_mappings.aliases
Фото продуктов → список Да (vision) То же
Фото блюда → калории Да (vision) Full-text search по recipes.title
Поиск рецептов из продуктов Нет SQL по mapping_id + Spoonacular findByIngredients
Ранжировка/подбор меню Да (текст) Получает recipe_id из БД, возвращает recipe_id
Замена ингредиентов Да (текст) Кеш в ingredient_substitutions
Нераспознанный ингредиент Да (текст, разово) Результат сохраняется в ingredient_mappings

6. Система очередей AI-запросов

Проблема

Gemini API имеет ограничения по RPM (requests per minute) и стоит денег. Нужно:

  1. Не допустить перерасход бюджета
  2. Дать приоритет платным пользователям
  3. Не блокировать систему при пиковых нагрузках

Архитектура

                        Входящий 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. Рецепты и контент

Источники рецептов

┌────────────────────────────────────────────────────┐
│                  Recipe Service                     │
│                                                    │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────┐ │
│  │ Spoonacular  │  │ AI-generated │  │ User     │ │
│  │ Import       │  │ Recipes      │  │ Recipes  │ │
│  │              │  │              │  │          │ │
│  │ source:      │  │ source:      │  │ source:  │ │
│  │ 'spoonacular'│  │ 'ai'         │  │ 'user'   │ │
│  └──────┬───────┘  └──────┬───────┘  └────┬─────┘ │
│         │                 │               │        │
│         └────────┬────────┘───────────────┘        │
│                  │                                  │
│                  ▼                                  │
│         ┌────────────────┐                         │
│         │  Единая таблица │                         │
│         │  recipes в БД   │                         │
│         └────────────────┘                         │
└────────────────────────────────────────────────────┘

Импорт из Spoonacular

  • Начальный импорт: 5 00010 000 самых популярных рецептов (по рейтингу).
  • Данные: название, описание, ингредиенты с граммовками, шаги приготовления, калории, БЖУ, время, сложность, кухня, фото, теги.
  • Хранение: в нашей БД (PostgreSQL). Не зависим от Spoonacular API при показе рецептов пользователю.
  • Синхронизация: фоновый джоб раз в неделю — обновление данных, добавление новых рецептов из популярных.
  • Spoonacular API используется также для: расширенного поиска (когда в локальной БД нет подходящего), справочника нутриентов, данных по ингредиентам.
  • Локализация: рецепты из Spoonacular на английском. Перевод — через Gemini Flash-Lite (batch, оффлайн). Переведённые рецепты кешируются в БД.

Согласованность AI и рецептов

Критическое правило: AI при подборе меню работает ТОЛЬКО с рецептами из нашей БД.

Поток:

  1. Backend выбирает кандидатов из БД по фильтрам (SQL-запрос: кухня, сложность, калории, ингредиенты).
  2. Backend формирует промпт с ID и метаданными кандидатов.
  3. AI ранжирует и компонует меню, возвращая recipe_id.
  4. Backend подгружает полные данные рецептов по ID.

Это гарантирует: точные нутриенты, наличие фото, корректные шаги, возможность оставить отзыв.

Исключение: AI-генерация нового рецепта «из того, что есть» — когда в БД нет подходящего. Такой рецепт сохраняется в БД как source = 'ai' и доступен другим пользователям после модерации (по рейтингу).


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           │
                   │   │ spoonacular_id   │
                   │   │ 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 для spoonacular)
                   │   └──────────────────┘
                   │
┌──────────────────┤   ┌──────────────────┐
│   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             │
│ spoonacular_id (UNIQUE)    │
│ 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 — каноническая таблица ингредиентов. aliases (JSONB) содержит все варианты написания на разных языках. spoonacular_id связывает с ингредиентами рецептов из Spoonacular. Нутриенты на 100г используются для пересчёта калорийности AI-генерированных рецептов. storage_days — дефолтный срок хранения для категории.
  • 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: spoonacular, 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 /recipes Каталог (фильтры, поиск, пагинация)
GET /recipes/{id} Карточка рецепта
GET /recipes/recommended Персональные рекомендации
GET /recipes/recent Недавно приготовленные
POST /recipes Создать пользовательский рецепт
POST /recipes/{id}/favorite Добавить в избранное
DELETE /recipes/{id}/favorite Убрать из избранного

Отзывы

Метод Путь Описание
GET /recipes/{id}/reviews Отзывы к рецепту
POST /recipes/{id}/reviews Написать отзыв

Меню

Метод Путь Описание
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 $50150 $5001 500 $2 5007 500
Spoonacular $29 $149 $149
Хостинг (Cloud Run / Fly.io) $1020 $50100 $200500
PostgreSQL (managed) $15 $50 $150300
S3 (фото) $5 $20 $50100
Итого $110220 $7701 820 $3 0508 550

Оптимизация затрат на AI

  • Кеширование результатов: замены ингредиентов, распознавание типовых блюд.
  • Batch-запросы: при генерации меню на неделю — один запрос вместо семи.
  • Снижение detail для фото: Gemini поддерживает low / high detail. Для чеков high, для фото продуктов low достаточно.
  • Мониторинг: дашборд затрат по ai_tasks — какие задачи дорогие, где можно оптимизировать промпты.