Метод GET /api/v1/cook/recipes/search

Документация API: Умный подбор и ранжирование рецептов на основе пересечения остатков холодильника со справочником Master Data

Published

July 1, 2026

1 Функциональное назначение

Метод предназначен для автоматического подбора кулинарных рецептов, которые семья или сотрудники офиса могут приготовить прямо сейчас, исходя из текущего состава продуктов в холодильнике (home_group_id).

Метод решает три ключевые задачи:

  1. Полнотекстовое и семантическое пересечение: Сверяет список уникальных канонических имен продуктов, находящихся на балансе пользователя, со списками ингредиентов в Мастер-базе рецептов.
  2. Гибкий порог вхождения: Возвращает рецепт в поисковую выдачу, если есть совпадение минимум по одному продукту. Рецепты, для которых не хватает части ингредиентов, не отсекаются, а ранжируются ниже.
  3. Защита от перегрузки (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 с жестким лимитом.

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: Экран мгновенно отрисовывает карточки: сверху рецепты с максимумом совпадений

4 Расшифровка шагов

4.1 Системный анализ и DTO-контракты приготовления по рецепту (Часть 1 из 2)

Шаг Действие Параметры / Запросы / DTO Ошибки (Исключения / Статусы)
Шаг 1 (APP -> Nginx) Клиент инициирует поиск рецептов в MDM-базе по конкретному выбранному продукту. HTTP GET /api/v1/recipes/search?product_id=XYZ
Headers: Authorization: Bearer <JWT>, Query: product_id="uuid-v4-string"
DioException: send timeout
HTTP 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=XYZ
Headers: X-Request-ID: "trace-recipe-search-uuid"
HTTP 422 Unprocessable Entity
Payload: ValidationErrorResponseDTO
error_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: ConnectionTimeout
PostgreSQL Exception: DiskReadError (сбой сектора диска СУБД)
Шаг 5 (Recipes -> GW -> APP) Сервис возвращает список подходящих рецептов, шлюз транслирует их на экран смартфона пользователя. HTTP Статус на выходе шлюза: 200 OK
Payload (RecipeListResponseDTO):
{ "recipes": [{ "recipe_id": "uuid", "title": "Борщ", "ingredients": [...] }] }
DioException: receive timeout (клиент разорвал сессию или ушел в авиарежим, не дождавшись рендеринга)
Шаг 6 (APP -> Nginx) Пользователь выбирает конкретный рецепт из списка и отправляет команду на инициацию процесса приготовления. HTTP POST /api/v1/cook/recipe-dish
Headers: Authorization: Bearer <JWT>
Payload (CookByRecipeRequestDTO):
{ "recipe_id": "uuid", "space_type": "OFFICE" }
DioException: connection timeout
HTTP 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-dish
Headers: X-Request-ID, X-User-ID
Валидатор: строгая проверка наличия обязательных полей recipe_id, space_type
HTTP 422 Unprocessable Entity
Payload: ValidationErrorResponseDTO
error_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; -- Защита от перегрузки оперативной памяти Flutter

5.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 перехватывает ошибку, не ломая интерфейс, и предлагает пользователю нажать кнопку «Повторить поиск».