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

836 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, название, калории, основные ингредиенты):
[список из 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` — какие задачи дорогие, где можно оптимизировать промпты.