sequenceDiagram
autonumber
actor User as Пользователь (Экран Поиска)
participant APP as Мобильное приложение (Flutter)
participant API as Бэкенд-шлюз (FastAPI)
participant DB as Реляционная СУБД (PostgreSQL)
User->>APP: Открывает вкладку "Подбор рецептов"
APP->>API: HTTP GET /api/v1/cook/recipes/search (С JWT токеном в Header)
activate API
note over API: Шаг 3: Извлечение home_group_id из JWT-токена
API->>DB: Вызов сложного SQL-запроса (CTE) по home_group_id
activate DB
note over DB: Шаг 5: Сбор уникальных SKU из fridge_inventory (quantity > 0)
note over DB: Шаг 6: Пересечение со словарем рецептов (Совпадение >= 1)
note over DB: Шаг 7: Расчет match_percentage и применение LIMIT 500
DB-->>API: Возвращает плоский массив подходящих рецептов (макс. 500 строк)
deactivate DB
API-->>APP: HTTP 200 OK (JSON с ранжированным списком рецептов)
deactivate API
note over APP: Экран мгновенно отрисовывает карточки: сверху рецепты с максимумом совпадений
Метод GET /api/v1/cook/recipes/search
Документация API: Умный подбор и ранжирование рецептов на основе пересечения остатков холодильника со справочником Master Data
- [DRAFT][MDM-MIGRATION-118](../../../../../tasks/MDM-003/EPIC-MDM-003/%5BMDM-118%5D%20%5BMigration%5D.qmd){.badge .bg-secondary} Backlog — Таблица хранения инцидентов цензуры и черновиков в СУБД.
- GW-44 Ready for Development — Подключение эндпоинта в шлюзе.
- MDM-010 Ready for Development — создать метод RecipeSearch.
- MDM-011 Ready for Development — создать метод validateAndFetchRecipe.
1 Функциональное назначение
Метод предназначен для автоматического подбора кулинарных рецептов, которые семья или сотрудники офиса могут приготовить прямо сейчас, исходя из текущего состава продуктов в холодильнике (home_group_id).
Метод решает три ключевые задачи:
- Полнотекстовое и семантическое пересечение: Сверяет список уникальных канонических имен продуктов, находящихся на балансе пользователя, со списками ингредиентов в Мастер-базе рецептов.
- Гибкий порог вхождения: Возвращает рецепт в поисковую выдачу, если есть совпадение минимум по одному продукту. Рецепты, для которых не хватает части ингредиентов, не отсекаются, а ранжируются ниже.
- Защита от перегрузки (Performance Limit): Устанавливает жесткое ограничение на стороне СУБД — в ответ попадает максимум 500 рецептов, отсортированных по коэффициенту максимального совпадения. Это защищает оперативную память мобильного приложения (Flutter) от переполнения тяжелыми JSON-пакетами.
2 Протокол взаимодействия (HTTP Контракт)
- Метод:
GET - Маршрут:
/api/v1/cook/recipes/search - Формат данных:
application/json(только ответ)
2.1 Спецификация параметров запроса (Query Parameters)
Поскольку метод использует метод GET, параметры фильтрации передаются внутри URL.
| Параметр | Тип | Обязательный | Описание | Пример значения |
|---|---|---|---|---|
limit |
Integer | Нет | Ограничение размера страницы выдачи (Максимум = 500) |
50 |
offset |
Integer | Нет | Смещение для постраничной навигации | 0 |
2.2 Спецификация заголовков (HTTP Headers)
| Заголовок | Обязательный | Описание | Пример значения |
|---|---|---|---|
Authorization |
Да | Токен авторизации (Access Token). Бэкенд извлекает из него home_group_id для получения состава холодильника. |
Bearer eyJhbGciOiJIUzI1Ni... |
X-Request-ID |
Да | Сквозной ID запроса для профилирования скорости тяжелого SQL-запроса | req-rec-search-99aa |
2.3 Спецификация структуры ответа (Response Body)
Метод возвращает массив рецептов. Для каждого рецепта рассчитывается match_percentage (сколько процентов ингредиентов рецепта уже есть в наличии).
| Поле | Тип | Описание | Пример значения |
|---|---|---|---|
recipe_id |
Integer | Уникальный ID рецепта в мастер-базе | 412 |
title |
String | Название блюда | "Яблочный пирог классический" |
match_percentage |
Float | Процент совпадения имеющихся продуктов с рецептом | 0.7500 (75%) |
total_ingredients |
Integer | Всего ингредиентов требуется в рецепте | 4 |
matched_count |
Integer | Сколько ингредиентов из требуемых есть в холодильнике | 3 |
2.3.1 Пример JSON-ответа (Payload):
{
"total_found": 142,
"limit": 500,
"offset": 0,
"recipes": [
{
"recipe_id": 412,
"title": "Яблочный пирог классический",
"match_percentage": 0.75,
"total_ingredients": 4,
"matched_count": 3,
"matched_items": ["Мука", "Масло сливочное 82%", "Апельсины"],
"missing_items": ["Яйцо куриное"]
}
]
}3 Схема обработки запроса пользователя (Mermaid)
На диаграмме представлен асинхронный процесс извлечения текущих остатков холодильника, сопоставления их со справочником рецептов на уровне СУБД с помощью агрегатных функций и выдачи упорядоченного списка во Flutter с жестким лимитом.
4 Расшифровка шагов
4.1 Системный анализ и DTO-контракты приготовления по рецепту (Часть 1 из 2)
| Шаг | Действие | Параметры / Запросы / DTO | Ошибки (Исключения / Статусы) |
|---|---|---|---|
Шаг 1 (APP -> Nginx) |
Клиент инициирует поиск рецептов в MDM-базе по конкретному выбранному продукту. | HTTP GET /api/v1/recipes/search?product_id=XYZHeaders: Authorization: Bearer <JWT>, Query: product_id="uuid-v4-string" |
DioException: send timeoutHTTP 400 Bad Request (передан некорректный текстовый формат вместо UUID) |
Шаг 2 (Nginx -> GW) |
Прокси-сервер выполняет базовую маршрутизацию и генерирует сквозной трассировочный идентификатор сессии. | HTTP GET (Внутреннее проксирование на FastAPI Gateway)Added Headers: X-Request-ID: "trace-recipe-search-uuid" |
HTTP 502 Bad Gateway (контейнер шлюза API Gateway недоступен / перегружен) |
Шаг 3 (GW -> Recipes) |
Шлюз валидирует типы данных через Pydantic-схему RecipeSearchQueryValidator и перенаправляет запрос в мастер-сервис. |
HTTP GET /internal/v1/recipes/search?product_id=XYZHeaders: X-Request-ID: "trace-recipe-search-uuid" |
HTTP 422 Unprocessable EntityPayload: ValidationErrorResponseDTOerror_code: "QUERY_VALIDATION_FAILED" |
Шаг 4 (Recipes -> СУБД) |
MDM-сервис рецептов выполняет выборку технологических карт из индексированной таблицы кулинарной книги. | SQL-запрос (SELECT):SELECT r.* FROM recipe_book r JOIN recipe_ingredients i ON r.id = i.recipe_id WHERE i.product_id = 'XYZ' LIMIT 500; |
PostgreSQL Exception: ConnectionTimeoutPostgreSQL Exception: DiskReadError (сбой сектора диска СУБД) |
Шаг 5 (Recipes -> GW -> APP) |
Сервис возвращает список подходящих рецептов, шлюз транслирует их на экран смартфона пользователя. | HTTP Статус на выходе шлюза: 200 OKPayload ( RecipeListResponseDTO):{ "recipes": [{ "recipe_id": "uuid", "title": "Борщ", "ingredients": [...] }] } |
DioException: receive timeout (клиент разорвал сессию или ушел в авиарежим, не дождавшись рендеринга) |
Шаг 6 (APP -> Nginx) |
Пользователь выбирает конкретный рецепт из списка и отправляет команду на инициацию процесса приготовления. | HTTP POST /api/v1/cook/recipe-dishHeaders: Authorization: Bearer <JWT>Payload ( CookByRecipeRequestDTO):{ "recipe_id": "uuid", "space_type": "OFFICE" } |
DioException: connection timeoutHTTP 401 Unauthorized (токен сессии скомпрометирован / просрочен) |
Шаг 7 (Nginx -> GW) |
Роутер Nginx терминирует защищенное SSL-соединение и обогащает HTTP-заголовки метаданными трассировки. | HTTP POST (Внутренний проброс запроса)Added Headers: X-Request-ID: "trace-cook-recipe-uuid"X-User-ID: "uuid-v4-user-string" |
HTTP 504 Gateway Timeout (превышено время ожидания ответа от бэкенда на уровне Nginx) |
Шаг 8 (GW -> Cook-Service) |
Шлюз FastAPI Gateway верифицирует структуру DTO и пробрасывает JSON-контракт инвентарному сервису. | HTTP POST /internal/v1/cook/recipe-dishHeaders: X-Request-ID, X-User-IDВалидатор: строгая проверка наличия обязательных полей recipe_id, space_type |
HTTP 422 Unprocessable EntityPayload: ValidationErrorResponseDTOerror_code: "PAYLOAD_VALIDATION_FAILED" |
Шаг 9 (Cook -> Recipes) |
Инвентарный сервис приготовления обращается к MDM-сервису через gRPC для верификации и извлечения состава. | gRPC Метод: RecipeMdmService.validateAndFetchRecipe()Protobuf ( RecipeFetchRequest):trace_id: "trace-cook-recipe-uuid"recipe_id: "uuid-v4-recipe-string" |
gRPC Status: NOT_FOUND (выбранный рецепт был удален из кулинарной книги куратором MDM)gRPC Status: UNAVAILABLE |
| Шаг | Действие | Параметры / Запросы / DTO | Ошибки (Исключения / Статусы) |
|---|---|---|---|
Шаг 10 (Recipes -> Cook) |
MDM-сервис верифицирует технологическую карту и возвращает точную спецификацию весов и состава ингредиентов. | Protobuf gRPC Response (RecipeSpecificationResponse): recipe_id: "uuid", spent_items: [{ "product_id": "uuid", "required_grams": 250.00 }] |
gRPC Status: INTERNAL (повреждена целостность данных связей recipe_ingredients в мастер-базе СУБД) |
Шаг 11 (Cook -> Fridge) |
Сервис приготовления транслирует полученный состав в gRPC-вызов балансового микросервиса склада холодильника. | gRPC Метод: FridgeService.mutateRecipeBalances() Protobuf ( MutateRecipeBalancesRequest): trace_id: "trace-cook-recipe-uuid", space_type: "OFFICE" / "HOME", spent_items: [...] |
gRPC Status: UNAVAILABLE (сервис склада изолирован нодой k8s / упал под)gRPC Status: DEADLINE_EXCEEDED |
Шаг 12 (Fridge -> СУБД) Контекст OFFICE |
Ветвление OFFICE: Выполняется строгая проверка остатков на полках. Списание разрешено только при условии полного покрытия. | SQL-запрос блокировки строк: SELECT balance FROM fridge_inventory WHERE product_id = 'uuid' FOR UPDATE; UPDATE fridge_inventory SET balance = balance - 250.00 WHERE product_id = 'uuid' AND balance >= 250.00; |
PostgreSQL Exception: CheckViolation (констрейнт balance >= 0 заблокировал выполнение при нехватке сырья) |
Шаг 13 (Fridge -> Cook) Дефицит OFFICE |
Прерывание OFFICE: Если обнаружен дефицит хотя бы по одному продукту, сервис холодильника откатывает транзакцию и отдает отказ. | gRPC Error Response (MutateRecipeBalancesResponse): gRPC Status: FAILED_PRECONDITION error_code: "INSUFFICIENT_STOCK" message: "Insufficient ingredients in office fridge" |
gRPC Status: FAILED_PRECONDITION (Бизнес-отказ: прерывание сессии приготовления из-за дефицита в офисе) |
Шаг 14 (Fridge -> СУБД) Контекст HOME |
Ветвление HOME: Допускается уход партий в реальный отрицательный баланс в СУБД (мягкое каскадное списание продуктов). | SQL-запрос мутации: UPDATE fridge_inventory SET balance = balance - 250.00 WHERE product_id = 'uuid'; Примечание: баланс физически принимает значение < 0 для сохранения истории дефицита. |
PostgreSQL Exception: DeadlockDetected (встречная блокировка при параллельной мутации балансов) |
Шаг 15 (Fridge -> Fridge) Маскирование Бэкенда |
Маскирование остатков: Слой бизнес-логики проверяет новые балансы. Если balance < 0, в DTO ответа значение подменяется на 0. |
Алгоритм маскирования (Backend Level): if item.balance < 0: masked_balance = 0.00 Настоящий минус остается в базе данных, но скрывается от клиентского интерфейса мобильного приложения. |
NullPointerException (сбой проверки объекта мутированной строки склада холодильника) |
Шаг 16 (Fridge -> СУБД) Финализация транзакции |
Логика склада атомарно фиксирует факт успешного завершения приготовления блюда по рецепту в транзакционной истории. | SQL-запрос истории: INSERT INTO cooking_history (id, user_id, recipe_id, status, trace_id) VALUES ('uuid', 'uuid', 'uuid', 'COOKED', 'trace-cook-recipe-uuid'); |
PostgreSQL Exception: DiskFull (нода СУБД переведена в режим Read-Only из-за исчерпания дисковых квот) |
Шаг 17 (Fridge -> Cook) |
Сервис холодильника передает очищенный, маскированный JSON-контракт остатков обратно в координатор приготовления. | Protobuf gRPC Response (MutateRecipeBalancesResponse): status: "OK", updated_items: [{ "product_id": "uuid", "balance": 0.00 }] |
gRPC Status: INTERNAL (критический сбой маршаллинга Protobuf-пакета на уровне сетевой сетевой карты) |
Шаг 18 (Cook -> GW -> APP) |
Инвентарный сервис приготовления закрывает транзакцию, и шлюз отдает клиенту успешный статус 200 OK. |
HTTP Статус на выходе шлюза: 200 OK Payload ( RecipeDishCookedSuccessDTO): { "status": "SUCCESS", "message": "Блюдо готово, остатки обновлены", "trace_id": "trace-cook-recipe-uuid" } |
DioException: receive timeout (фронтенд-клиент разорвал сессию, не дождавшись десериализации HTTP-ответа) |
Шаг 19 (Cook -> Kafka -> Bupar) |
Сервис приготовления публикует асинхронный лог аудита. Сервис Bupar считывает его и делает запись для Process Mining (PMA). | Kafka Topic: bpds.inventory.out.meal_item.create Event Payload: { "trace_id": "trace-cook-recipe-uuid", "event_type": "RECIPE_DISH_COOKED", "timestamp": 1784562120 } SQL Bupar: INSERT INTO bupar_event_logs (trace_id, activity) VALUES ('trace-recipe', 'RECIPE_DISH_COOKED'); |
Отказ Kafka: NotWritablePartitionException Очередь дефектов: Сброс поврежденного лога в bpds.inventory.out.meal_item.create.DLQ |
5 Спецификация серверной логики (SQL) и вилок исключений
5.1 1. Архитектурный SQL-запрос подбора рецептов (Шаги 5–7)
Для реализации бизнес-логики (совпадение минимум по 1 ингредиенту, расчет процента совпадения и жесткий лимит в 500 строк) на уровне PostgreSQL используется оптимизированный запрос с обобщенными табличными выражениями (CTE) и агрегацией.
WITH user_fridge AS (
-- Шаг 5: Выделяем уникальный профиль доступных продуктов пользователя (без минусов)
SELECT DISTINCT DISTINCT ON (LOWER(product_name)) LOWER(product_name) AS food_name
FROM fridge_inventory
WHERE home_group_id = \$1 AND quantity > 0
),
recipe_matching AS (
-- Шаг 6: Сопоставляем ингредиенты рецептов с продуктами в холодильнике
SELECT
ri.recipe_id,
COUNT(ri.ingredient_name) AS total_needed,
SUM(CASE WHEN uf.food_name IS NOT NULL THEN 1 ELSE 0 END) AS matched_count,
ARRAY_AGG(ri.ingredient_name) FILTER (WHERE uf.food_name IS NOT NULL) AS matched_items,
ARRAY_AGG(ri.ingredient_name) FILTER (WHERE uf.food_name IS NULL) AS missing_items
FROM recipe_ingredients ri
LEFT JOIN user_fridge uf ON LOWER(ri.ingredient_name) = uf.food_name
GROUP BY ri.recipe_id
)
-- Шаг 7: Фильтруем (минимум 1 совпадение), ранжируем и накладываем жесткий лимит
SELECT
r.id AS recipe_id,
r.title,
(rm.matched_count::FLOAT / rm.total_needed::FLOAT) AS match_percentage,
rm.total_needed AS total_ingredients,
rm.matched_count,
rm.matched_items,
rm.missing_items
FROM recipe_matching rm
JOIN recipes r ON r.id = rm.recipe_id
WHERE rm.matched_count >= 1 -- Критерий: совпадение минимум по одному продукту
ORDER BY match_percentage DESC, rm.matched_count DESC
LIMIT 500; -- Защита от перегрузки оперативной памяти Flutter5.2 2. Спецификация ответов сервера (Response Body)
5.2.1 HTTP 200 OK (Ответ на Шаге 9)
Возвращается при успешной выборке рецептов. Массивы matched_items и missing_items позволяют Flutter-приложению наглядно красить ингредиенты на экране (зеленый/красный цвет).
{
"total_found": 3,
"limit": 500,
"offset": 0,
"recipes": [
{
"recipe_id": 412,
"title": "Яблочный пирог классический",
"match_percentage": 0.75,
"total_ingredients": 4,
"matched_count": 3,
"matched_items": ["Мука", "Масло сливочное 82%", "Яблоки"],
"missing_items": ["Яйцо куриное"]
},
{
"recipe_id": 105,
"title": "Утренний омлет с зеленью",
"match_percentage": 0.3333,
"total_ingredients": 3,
"matched_count": 1,
"matched_items": ["Масло сливочное 82%"],
"missing_items": ["Яйцо куриное", "Молоко 3.2%"]
}
]
}5.3 3. Вилки исключений и стратегии обработки ошибок
5.3.1 Сценарий А: Пустой холодильник (HTTP 200 OK с пустым массивом)
- Условие: У данной семьи (
home_group_id) в таблицеfridge_inventoryнет ни одной записи сquantity > 0(холодильник пуст или все продукты в виртуальном минусе). - Действие системы: SQL-запрос отработает корректно и вернет 0 строк. Бэкенд возвращает статус
200 OKс пустым массивомrecipes: []. Flutter перехватывает этот стейт и вместо падения рендерит заглушку: «В вашем холодильнике нет доступных продуктов. Пожалуйста, импортируйте ресурсы (сканируйте чек или добавьте фото), чтобы ИИ подобрал рецепты».
5.3.2 Сценарий Б: Превышение системного лимита запроса (HTTP 400 Bad Request)
- Условие: Клиентское приложение попыталось передать через Query-параметры кастомный
limitбольше, чем зафиксировано в архитектурном стандарте системы (limit=1000). - Действие FastAPI: Срабатывает валидация Pydantic на бэкенд-шлюзе. Запрос отсекается до похода в БД.
{
"error_code": "ERR-MAX-LIMIT-EXCEEDED",
"message": "Передан некорректный параметр страницы. Максимальный лимит выдачи составляет 500 рецептов.",
"details": {
"requested_limit": 1000,
"allowed_max_limit": 500
}
}5.3.3 Сценарий В: Таймаут СУБД из-за высокой параллельной нагрузки (HTTP 503)
- Условие (Шаг 4): Пул соединений к PostgreSQL перегружен, и тяжелый аналитический
JOINне успевает выполниться за установленный таймаут в 2000 мс. - Действие FastAPI: Шлюз возвращает ошибку
ERR-DATABASE-OFFLINEсо статусомHTTP 503 Service Unavailable. На клиенте Dio перехватывает ошибку, не ломая интерфейс, и предлагает пользователю нажать кнопку «Повторить поиск».