# 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 - **Выход:** ```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) - **Вход:** фото готового блюда - **Выход:** ```json { "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, название, калории, основные ингредиенты): [список из 50–100 кандидатов из БД] Задача: составь меню на 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 000–10 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 | $50–150 | $500–1 500 | $2 500–7 500 | | Spoonacular | $29 | $149 | $149 | | Хостинг (Cloud Run / Fly.io) | $10–20 | $50–100 | $200–500 | | PostgreSQL (managed) | $15 | $50 | $150–300 | | S3 (фото) | $5 | $20 | $50–100 | | **Итого** | **$110–220** | **$770–1 820** | **$3 050–8 550** | ### Оптимизация затрат на AI - **Кеширование результатов:** замены ингредиентов, распознавание типовых блюд. - **Batch-запросы:** при генерации меню на неделю — один запрос вместо семи. - **Снижение detail для фото:** Gemini поддерживает `low` / `high` detail. Для чеков `high`, для фото продуктов `low` достаточно. - **Мониторинг:** дашборд затрат по `ai_tasks` — какие задачи дорогие, где можно оптимизировать промпты.