Метод POST /api/v1/fridge/add-by-photo

Документация API: Добавление продуктов через готовый снимок с автоматической gRPC ИИ-модерацией

Published

June 11, 2026

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

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

2 Протокол взаимодействия (HTTP Контракт)

  • Метод: POST
  • Маршрут: /api/v1/fridge/add-by-photo
  • Формат данных: multipart/(jpeg)

2.1 Спецификация заголовков (HTTP Headers)

Обязательные заголовки для передачи бинарного файла, авторизации и сквозного логирования асинхронного трейсинга.

Заголовок Обязательный Описание Пример значения
Content-Type Да Указывает на передачу составных данных формы (мультипарт) multipart/form-data; boundary=----WebKitFormBoundary...
Authorization Да Токен авторизации пользователя для определения user_id и home_group_id Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-Request-ID Да Сквозной ID запроса. Генерируется на Flutter (Dio) или на Nginx для трейсинга всей цепочки (FastAPI -> S3 -> gRPC -> Kafka) req-5f9c-2b1a-88ef
Accept Нет Ожидаемый формат ответа от бэкенд-шлюза application/json

2.2 Спецификация тела запроса (Request Body)

Поле Тип Обязательный Описание Пример значения
file Binary (File) Да Бинарный файл фотографии продукта или товарного чека (image/jpeg, image/png) snapshot.jpg
crop_type String Нет Режим кадрирования для ИИ-воркера (default, square, raw) "square"

3 Схема обработки запроса пользователя (Mermaid)

sequenceDiagram
    autonumber
    actor User as Пользователь (Flutter)
    participant APP as Мобильное приложение (Dio)
    participant NGINX as Прокси-сервер (Nginx)
    participant API as Бэкенд-шлюз (FastAPI)
    participant S3 as Хранилище (MinIO S3)
    participant DB as Реляционная СУБД (PostgreSQL)
    participant GRPC as ИИ-сервис (PyTorch / gRPC)
    participant KAFKA as Брокер событий (Kafka)

    %% ЭТАП 1: ЗАГРУЗКА И ФИКСАЦИЯ ЧЕРНОВИКА
    User->>APP: Нажимает "ПАКЕТ ЧЕКА v1" и выбирает фото
    APP->>NGINX: HTTP POST /product-photo (multipart бинарник)
    NGINX->>API: Проксирует HTTP POST /product-photo
    activate API
    API->>S3: s3_client.upload_fileobj() -> Сохраняет картинку апельсина/чека
    API->>DB: INSERT INTO pending_receipts_buffer (status='PROCESSING') -> Фиксирует UUID черновика
    API-->>NGINX: Возвращает HTTP 202 Accepted (draft_id = UUID)
    NGINX-->>APP: Транслирует HTTP 202 (Экран свободен для пользователя)
    
    %% ЭТАП 2: АСИНХРОННЫЙ ИИ-ИНФЕРЕНС И МОДЕРАЦИЯ
    Note over API, GRPC: Запуск фонового фокус-воркера async_bupar_ai_worker
    API->>GRPC: gRPC CheckProduct(draft_id, s3_key, crop_type)
    deactivate API
    activate GRPC
    GRPC->>S3: Скачивает бинарные байты картинки по s3_key
    GRPC->>GRPC: Определение является ли это продуктом или чеком
    GRPC->>GRPC: PyTorch (MobileNetV2) -> Расчет тензора весов
    Note over GRPC: Выделен ID класса 950 (Авто-маппинг в имя "ORANGE")
    GRPC-->>API: gRPC ImageResponse(status='PENDING', info='ORANGE')
    deactivate GRPC
    
    activate API
    Note over API: Проверка типа ответа (Строка, не JSON) -> Запуск вилки исключений
    API->>DB: INSERT INTO product_moderation_tickets (status='PENDING') -> Создает тикет
    API->>KAFKA: send_to_kafka_async("analytics.dlq.v1") -> Лог инцидента SKU для MLOps
    API->>APP: WebSocket /ws/push-stream/{id} -> Сигнал "NEW_MODERATION_TICKET"
    Note over APP: Вкладка "Модерация ИИ" мгновенно обновляется и выводит карточку ORANGE
    deactivate API

    %% ЭТАП 3: РУЧНОЕ ИСПРАВЛЕНИЕ И ЗАЧИСЛЕНИЕ НА СКЛАД
    User->>APP: Стирает ORANGE, вводит "АПЕЛЬСИНЫ" и жмет "Утвердить"
    APP->>NGINX: HTTP POST /receipt-draft/{UUID}/confirm (С чистым double "qty": 1.0)
    NGINX->>API: Проксирует HTTP POST /confirm
    activate API
    API->>DB: SELECT FROM product_moderation_tickets (Проверка статуса)
    
    Note over API, DB: Старт атомарной SQL-транзакции
    API->>DB: INSERT INTO bupar_event_log (activity='PURCHASE') -> Лог для Process Mining в R
    API->>DB: INSERT INTO fridge_inventory ON CONFLICT DO UPDATE -> Начисление продуктов
    API->>DB: UPDATE product_moderation_tickets (status='CONFIRMED') -> Закрытие тикета
    
    API->>KAFKA: send_to_kafka_async("bupar.fridge.events.add") -> Сигнал изменения остатков
    API-->>NGINX: Возвращает HTTP 201 Created
    deactivate API
    NGINX-->>APP: Транслирует HTTP 201 Created
    APP->>DB: Авто-запрос GET /fridge/stock -> Перечитывает актуальный склад
    Note over APP: Карточка тикета исчезает, на складе появляются реальные апельсины!

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

4.1 Шаги 1–6: Загрузка файла и первичная фиксация

  • Шаг 1 (User -> APP): Пользователь во Flutter-интерфейсе нажимает кнопку «ПАКЕТ ЧЕКА v1» и выбирает фотографию из галереи или камеры.
  • Шаг 2 (APP -> NGINX): Приложение через клиент Dio отправляет бинарный файл изображения методом HTTP POST /product-photo (формат multipart/form-data) на прокси-сервер.
  • Шаг 3 (NGINX -> API): Nginx принимает запрос и перенаправляет (проксирует) его на бэкенд-шлюз FastAPI. В бэкенде активируется контекст обработки (activate API).
  • Шаг 4 (API -> S3): FastAPI вызывает асинхронный метод s3_client.upload_fileobj(), отправляя бинарный поток фотографии апельсина/чека на сохранение в бакет MinIO S3.
    • Вилка исключений: Если метод возвращает статус REJECTED или хранилище недоступно, бэкенд прерывает транзакцию и выбрасывает ошибку цензуры ERR-BUCKET (HTTP 400). Ошибка маппится с текстом для модального окна на клиенте.
  • Шаг 5 (API -> DB): Бэкенд делает запись в базу данных PostgreSQL: INSERT INTO pending_receipts_buffer (status='PROCESSING'). Этим он резервирует буфер и генерирует уникальный UUID (draft_id) для черновика.
    • Вилка исключений: Если база данных недоступна, система возвращает ошибку, прерывая операцию.
  • Шаг 6 (API -> NGINX): Бэкенд мгновенно, не дожидаясь ИИ, возвращает прокси-серверу ответ HTTP 202 Accepted вместе со сгенерированным draft_id = UUID.

4.2 Шаги 7–14: Асинхронный ИИ-инференс, генерация тикета и Push-уведомление

  • Шаг 7 (NGINX -> APP): Nginx транслирует ответ HTTP 202 в мобильное приложение. Экран смартфона освобождается для пользователя, он может идти дальше по приложению.
  • Шаг 8 (API -> GRPC): На бэкенде фоновый воркер async_bupar_ai_worker инициирует удаленный вызов к ИИ-сервису через метод gRPC CheckProduct(draft_id, s3_key, crop_type). Шлюз FastAPI временно деактивируется, а gRPC-сервер активируется (activate GRPC).
    • Стратегия Fail-Safe: Если gRPC-сервер оффлайн, система пишет предупреждение [Moderation Warning] в системный лог и пропускает запрос по сценарию ограниченного доверия, чтобы не блокировать действия реального человека.
  • Шаг 9 (GRPC -> S3): ИИ-сервис на базе PyTorch обращается к хранилищу MinIO S3 и скачивает сырые байты картинки по переданному адресу s3_key.
  • Шаг 10 (GRPC -> GRPC): Внутренний шаг ИИ-сервиса: нейросеть PyTorch (MobileNetV2) рассчитывает тензор весов. На выходе определяется ID класса 950, который автоматически маппится в строку "ORANGE".
  • Шаг 11 (GRPC -> API): ИИ-сервис возвращает структурированный gRPC-ответ ImageResponse(status='PENDING', info='ORANGE') обратно на бэкенд FastAPI. Работа gRPC-сервера завершается.
    • Вилка исключений: Если ИИ-модератор возвращает статус REJECTED, бэкенд прерывает транзакцию и выбрасывает ошибку цензуры ERR-BUPAR-CENSOR (HTTP 400).
  • Шаг 12 (API -> DB): FastAPI снова активен. Происходит проверка типа ответа (ожидается строка, не JSON) — в случае несовпадения запускается вилка исключений. Затем создаётся запись в БД: INSERT INTO product_moderation_tickets (status='PENDING') для ручного подтверждения.
  • Шаг 13 (API -> KAFKA): Бэкенд отправляет асинхронное сообщение send_to_kafka_async("analytics.dlq.v1"). Этот инцидент SKU логируется в брокер сообщений для команды MLOps.
  • Шаг 14 (API -> APP): Бэкенд через открытый WebSocket-канал /ws/push-stream/{id} отправляет клиенту push-сигнал "NEW_MODERATION_TICKET". У пользователя во Flutter мгновенно обновляется вкладка «Модерация ИИ» и выводится карточка ORANGE. Контекст API засыпает.

4.3 Шаги 15–22: Ручное исправление, транзакция в БД и обновление остатков

  • Шаг 15 (User -> APP): Пользователь видит карточку ORANGE, вручную стирает её, пишет "АПЕЛЬСИНЫ" и нажимает кнопку «Утвердить». Чистый текст разбивается по разделителям (пробелам). Первое слово интерпретируется как наименование продукта, второе — преобразуется в тип float как количество (дефолтное значение = 1.0).
  • Шаг 16 (APP -> NGINX): Приложение отправляет запрос HTTP POST /receipt-draft/{UUID}/confirm с телом "qty": 1.0 (тип double) на прокси-сервер.
  • Шаг 17 (NGINX -> API): Nginx перенаправляет этот POST-запрос на бэкенд FastAPI. Бэкенд снова активируется.
  • Шаг 18 (API -> DB): Бэкенд идет в базу данных с запросом SELECT FROM product_moderation_tickets, чтобы проверить статус тикета (защита от повторного подтверждения). В этот момент стартует атомарная SQL-транзакция, внутри которой последовательно выполняются еще три операции:
    1. INSERT INTO bupar_event_log (activity='PURCHASE') — логирование для Process Mining в R.
    2. INSERT INTO fridge_inventory ON CONFLICT DO UPDATE — фиксация в базе данных. В случае коллизии (если такой продукт уже добавлен в рамках этой семьи / home_group_id), срабатывает логика инкремента остатков: DO UPDATE SET quantity = fridge_inventory.quantity + EXCLUDED.quantity.
    3. UPDATE product_moderation_tickets (status='CONFIRMED') — закрытие тикета.
  • Шаг 19 (API -> KAFKA): После успешного коммита транзакции в БД, бэкенд отправляет событие send_to_kafka_async("bupar.fridge.events.add"), сигнализируя всей системе, что остатки продуктов на складе изменились.
  • Шаг 20 (API -> NGINX): Бэкенд сообщает об успехе, возвращая статус HTTP 201 Created прокси-серверу.
  • Шаг 21 (NGINX -> APP): Nginx транслирует статус HTTP 201 Created в мобильное приложение. Контекст API завершается.
  • Шаг 22 (APP -> DB): Приложение лочит статус 201 и делает автоматический фоновый запрос GET /fridge/stock (для вычитывания актуального склада), карточка тикета исчезает, и на экране пользователя появляются реальные апельсины.

5 Спецификация ответов сервера (Response Body)

5.1 Успешное добавление (201 Created)

{
  "status": "SUCCESS",
  "message": "Продукт 'Яблоки' успешно отмодерирован и добавлен текстовым контуром."
}

5.2 Ошибка модерации (400 Bad Request)

Возвращается, если ИИ-контур зафиксировал мат или спам в текстовом вводе реального пользователя.

{
  "code": "ERR-BUPAR-CENSOR",
  "detail": "Текст не прошел модерацию: обнаружена ненормативная лексика или спам."
}

6 Спецификация ответов сервера (Response Body) и ошибок

6.1 1. Успешные ответы (Success Responses)

6.1.1 HTTP 202 Accepted (Ответ на Шаге 6 — Загрузка фото)

Возвращается мгновенно после успешного сохранения бинарного файла в MinIO S3 и фиксации черновика в PostgreSQL.

  • Заголовки ответа (Response Headers):
    • Content-Type: application/json
  • Тело ответа (Response Body):
{
  "status": "PROCESSING",
  "draft_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "message": "Файл успешно загружен. Запущен асинхронный контур ИИ-модерации."
}

6.1.2 HTTP 201 Created (Ответ на Шаге 21 — Подтверждение)

Возвращается после успешного завершения атомарной транзакции в PostgreSQL и отправки бизнес-события в Kafka.

  • Заголовки ответа (Response Headers):
    • Content-Type: application/json
  • Тело ответа (Response Body):
{
  "status": "CONFIRMED",
  "product_name": "АПЕЛЬСИНЫ",
  "applied_quantity": 1.0,
  "unit": "шт",
  "timestamp": "2026-07-01T20:21:00Z"
}

6.2 2. Спецификация ошибок и вилок исключений (Error Responses)

Все ошибки бэкенда возвращаются в едином стандартизированном формате RFC 7807 (Problem Details) или стандартном JSON-формате FastAPI HTTPException. Это позволяет Flutter-клиенту (Dio) однозначно парсить код ошибки и выводить корректный текст в модальных окнах.

6.2.1 Ошибка цензуры бакета (HTTP 400 Bad Request — Шаг 4)

Выбрасывается, если контур загрузки в S3 заблокировал файл или S3 вернул отказ.

{
  "error_code": "ERR-BUCKET",
  "message": "Не удалось сохранить или верифицировать медиафайл во внутреннем хранилище.",
  "details": {
    "reason": "S3 bucket storage rejected input stream or content validation failed."
  }
}

6.2.2 Ошибка ИИ-цензурирования (HTTP 400 Bad Request — Шаг 11)

Выбрасывается воркером, если ИИ-модератор через gRPC вернул статус REJECTED (обнаружен спам, ненормативная лексика или изображение не содержит продуктов/чеков).

{
  "error_code": "ERR-BUPAR-CENSOR",
  "message": "Изображение не прошло автоматическую проверку ИИ-модератором.",
  "details": {
    "reason": "Spam, explicit content, or invalid SKU pattern detected by MobileNetV2."
  }
}

6.2.3 Ошибка валидации структуры (HTTP 422 Unprocessable Entity — Шаг 12 или 17)

Выбрасывается FastAPI, если gRPC-сервер вернул некорректный тип данных (JSON вместо строки), либо если клиент передал невалидный UUID / отрицательный double qty на Шаге 17.

{
  "error_code": "ERR-VALIDATION-FAILED",
  "message": "Переданные данные не соответствуют ожидаемой схеме валидации Pydantic.",
  "details": [
    {
      "loc": ["body", "qty"],
      "msg": "Value must be greater than 0.0",
      "type": "value_error.number.not_gt"
    }
  ]
}

6.2.4 Ошибка авторизации (HTTP 401 Unauthorized — Триггер Interceptor)

Выбрасывается на Шаге 3 или Шаге 18, если Access Token просрочен, подпись невалидна или токен отсутствует в заголовках.

{
  "error_code": "ERR-UNAUTHORIZED",
  "message": "Срок действия сессии истек. Требуется обновление токена.",
  "details": {
    "action": "Trigger queued token refresh mechanism using refresh_token."
  }
}

6.2.5 Ошибка недоступности СУБД/Сервисов (HTTP 503 Service Unavailable)

Выбрасывается на Шаге 5 или 19, если PostgreSQL находится под высокой нагрузкой или пул соединений переполнен.

{
  "error_code": "ERR-DATABASE-OFFLINE",
  "message": "Сервис временно недоступен. Пожалуйста, повторите попытку позже.",
  "details": {
    "reason": "PostgreSQL connection pool timeout or database node partition."
  }
}