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>
836 lines
52 KiB
Markdown
836 lines
52 KiB
Markdown
# 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` — какие задачи дорогие, где можно оптимизировать промпты.
|