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 после обработки/модерации, синхронного подтверждения нет.
Рассмотренные варианты
media/saveсо ссылкой на наш URL — нужен публичный стабильный URL и список текущих фото карточки (иначе затрём остальные); ошибка одного файла «тихо» отменяет всё.media/file(multipart) на позицию — меняет ровно одно фото, не трогает остальные, не требует публичного URL для WB. Выбран.
Хранилище фото:
- Локальная ФС — у контейнеров Coolify она эфемерна, отпадает.
- S3/объектное хранилище — в проекте нет (контент-модуль OP-141/142 хранит
только тексты в
generated_contents), вводить инфраструктуру ради единиц мегабайт на тест избыточно. - 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 → 502WB_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) для сохранения оригинала.