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>
This commit is contained in:
dbastrikin
2026-02-20 13:14:58 +02:00
commit 24219b611e
140 changed files with 13062 additions and 0 deletions

835
docs/Tech.md Normal file
View File

@@ -0,0 +1,835 @@
# 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` — какие задачи дорогие, где можно оптимизировать промпты.