Перейти к основному содержимому

0062. Фото варианта A/B-теста: хранение и выкладка на карточку WB (Content API)

Копия ADR из репозитория: backend/docs/adr/0062-ab-test-variant-photo-apply.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-09-28
  • Связанные задачи: OP-143…146 (5.14), spike

Контекст и проблема​

Вариант A/B-теста был только меткой в БД: фото на карточке WB не менялось, загрузить JPG было некуда, а каталог (catalog sync) работает только на чтение. Нужно: хранить фото варианта, отдавать его по URL (payload.imageUrl) и выкладывать на карточку через WB Content API.

Spike: WB Content API «Медиафайлы»​

Источник — wildberries-api/02-products.yaml, indexes/methods.jsonl. Base URL https://content-api.wildberries.ru (sandbox https://content-api-sandbox.wildberries.ru), авторизация — заголовок Authorization: <токен> (категория токена «Контент»).

МетодЧто делаетВход
POST /content/v3/media/fileзагружает один файл в карточку на позициюmultipart/form-data, поле uploadfile; заголовки X-Nm-Id (nmId), X-Photo-Number (с 1; видео — всегда 1)
POST /content/v3/media/saveзагружает набор по ссылкамJSON { nmId, data: [url, …] }

Ограничения:

  • media/save полностью заменяет весь набор медиа карточки: чтобы добавить, нужно передать ссылки и на старые файлы. Ссылки должны вести прямо на файл, без авторизации. Если хоть один файл не прошёл требования — не загрузится ни один, даже при ответе 200.
  • media/file: номер больше числа загруженных фото — добавление, иначе — замена фото на этой позиции.
  • Фото: ≤ 30 на карточку, ≥ 700×900 px, ≤ 32 МБ, качество ≥ 65%, JPG/PNG/BMP/GIF(статичный)/WebP. Видео — одно, ≤ 50 МБ, MOV/MP4.
  • Ответ 200 может нести { error: true, errorText } — это отказ. Коды ошибок: 400/401/402/403/429.
  • Лимит: категория «Контент» — 100 запросов/мин на аккаунт, интервал 600 мс, всплеск 5 (уже в wb-rate-limits.ts: content: 100/60s).
  • Результат асинхронный: фото появляется на витрине WB после обработки/модерации, синхронного подтверждения нет.

Рассмотренные варианты​

  1. media/save со ссылкой на наш URL — нужен публичный стабильный URL и список текущих фото карточки (иначе затрём остальные); ошибка одного файла «тихо» отменяет всё.
  2. media/file (multipart) на позицию — меняет ровно одно фото, не трогает остальные, не требует публичного URL для WB. Выбран.

Хранилище фото:

  1. Локальная ФС — у контейнеров Coolify она эфемерна, отпадает.
  2. S3/объектное хранилище — в проекте нет (контент-модуль OP-141/142 хранит только тексты в generated_contents), вводить инфраструктуру ради единиц мегабайт на тест избыточно.
  3. PostgreSQL bytea (ab_test_variant_images) — выбран.

Решение​

  • ab_test_variant_images (bytea, одна строка на вариант, sha256, MIME по сигнатуре файла — JPEG/PNG/WebP, лимит 10 МБ). Загрузка: POST /ab-tests/:id/variants/:variantId/image (multipart, поле file) → payload.imageId/imageUrl/imageContentType/imageSizeBytes/imageUploadedAt. Повторная загрузка заменяет фото (новый id) и сбрасывает payload.appliedAt.
  • Отдача: GET /ab-test-images/:imageId — публично (@Public()): URL вставляется во фронте в <img>, где токен не передать; id — случайный UUID; Cache-Control: immutable, Cross-Origin-Resource-Policy: cross-origin.
  • Выкладка: POST /ab-tests/:id/variants/:variantId/apply (photoNumber, по умолчанию 1) → media/file через WbApiClient (расширен полями formData/headers): общий rate-limiter, retry, маппинг ошибок WB (401/403 → 502 WB_AUTH_FAILED, 429 → 429 + Retry-After); error: true при 200 → 502. Каждая попытка — в аудит ab-test.variant.apply (success/failure). В running-тесте после выкладки вариант автоматически активируется (новый период метрик, ADR-0065).
  • POST /ab-tests/:id/apply-winner — то же для победителя completed-теста.

Последствия​

  • Положительные: фото реально меняется на карточке; есть аудит, что и когда выложено (sha256); ни одного нового инфраструктурного компонента.
  • Отрицательные / компромиссы: байты в БД увеличивают размер бэкапов; разрешение (700×900) не проверяется локально — отказ придёт от WB; исходное фото карточки не сохраняется (откат — выложить контрольный вариант со своим фото). Проверено только с моком WB — на реальном токене нужна ручная проверка.
  • Дальше: при появлении объектного хранилища — перенести байты туда; подтягивать текущие фото карточки (/content/v2/get/cards/list) для сохранения оригинала.