# Работа с изображениями товаров через CDN и productImageSizes

## Контекст

Раньше изображения товаров отдавались напрямую с веб-сервера: оригинальные файлы лежали в `web/<publicImageDir>/...`, тамбнейлы делались по требованию через `imageResize()` или хранились рядом с суффиксом `_300x200`. Старые методы построены на проверке существования файла на диске и при отсутствии возвращают плейсхолдер.

Параллельно в БД появились таблицы `productImageSizes` и `subProductImageSizes` (миграции `m250822_215420_create_productImageSizes_table` и `m260315_000001_create_subProductImageSizes_table`). В них уже хранятся уменьшенные версии каждого изображения (`sm`/`md`/`lg`/`xl`/`original`) с реальными именами файлов и хешами. Сами файлы заливаются на CDN — для каждой темы свой домен.

Новые методы — это «вторая дорожка» поверх существующего кода. Они тянут URL из `productImageSizes` / `subProductImageSizes`, формируют ссылку на CDN и при любой нештатной ситуации (CDN не задан, нужного размера нет в БД и т.п.) откатываются на старые методы. Старые методы не удалены — они помечены комментарием «Использовалось раньше …» и продолжают работать как fallback и для тех тем, где CDN ещё не настроен.

## Конфигурация CDN

CDN привязан к теме. Имя CDN-домена задаётся в `commands/ThemeController.php` в массиве `$themes`:

```php
'mebelbazar' => [
  'views' => '@app/themes/mebelbazar',
  'publicImageDir' =>  'media',
  'cdn' => 'img.mebelibazar.ru',
  ...
],
```

При выполнении команды

```bash
docker exec php74_mebel php yii theme mebelbazar
```

значение поля `cdn` записывается в `config/params.php` (ключ `"cdn"`). Если поле пустое, новые методы откатываются на старую логику.

CDN отдаёт статический файл по URL вида `https://<cdn>/<filename>`, где `<filename>` — это `productImageSizes.filename` (или `subProductImageSizes.filename`). Никаких подписанных или временных ссылок не предполагается.

## Размеры

В `productImageSizes.sizeAlias` хранятся следующие алиасы: `original`, `sm`, `md`, `lg`, `xl`. Для отображения используются:

| Где | Используемый размер |
|---|---|
| Карточка товара в каталоге (списки, related/similar/collection, hit-блоки) | `sm` |
| Страница товара (главный слайдер, миниатюры, конструктор комплектации) | `lg`, при отсутствии — `md`, при отсутствии — `sm` |

Размеры `original` и `xl` сейчас в шаблонах не используются.

## Методы

### `app\modules\admin\models\ProductImage`

- `getSizes()` — связь `hasMany(ProductImageSizes, productImageId)`. Используется, если нужно достать сразу набор размеров одной картинки.
- `getCdnUrl(string $sizeAlias): ?string` — низкоуровневый метод. Берёт `params['cdn']`, ищет строку в `productImageSizes` по `productImageId` + `sizeAlias`. Возвращает `https://<cdn>/<filename>` либо `null`, если CDN не задан или нужного размера нет.
- `getCatalogImage(): string` — URL для каталога. Делает `getCdnUrl('sm')`, при `null` откатывается на старый `getPublicImageThumb()`.
- `getCardImage(): string` — URL для карточки товара. По очереди пробует `lg`, `md`, `sm`. Если ничего нет в CDN — старый `getPublicImageFull()`.

Старые `getPublicImageThumb()` и `getPublicImageFull()` сохранены и работают как раньше (с проверкой файла на диске и плейсхолдером `via.placeholder.com`).

### `app\modules\admin\models\SubProduct`

Полный аналог `ProductImage`, но через таблицу `subProductImageSizes`:

- `getSizes()` — связь с `SubProductImageSizes`.
- `getCdnUrl($sizeAlias)`.
- `getCatalogImage()` — `sm` через CDN, fallback на старый `getPublicImageThumb()`.
- `getCardImage()` — `lg → md → sm`, fallback на старый `getPublicImageThumb()`.

Старый `getPublicImageThumb()` сохранён.

### `app\modules\admin\models\Product`

Высокоуровневые методы — выбирают «правильную» картинку для всего товара с учётом его типа.

- `getFirstProductImage(): ?ProductImage` (protected) — для `simpleOption` берёт первую картинку preset-комбинации `ppvPrice.images`, для всех остальных — первую активную `ProductImage` товара по `sorting`.
- `getCatalogImage(): string`:
    - для `import-pm` — внешний URL из `product.image` (как и раньше);
    - для всех остальных — `getFirstProductImage()->getCatalogImage()`;
    - если у товара нет `ProductImage` — старый `getPublicImageThumb()`.
- `getCardImage(): string`:
    - для `import-pm` — `product.image`;
    - для всех остальных — `getFirstProductImage()->getCardImage()`;
    - fallback — старый `getPublicImageThumb()`.
- `getPpvPresetImagesCdn(): array` — аналог старого `getPpvPresetImages()`, но возвращает `['image' => $i->getCardImage(), 'title' => $i->title]` для каждой картинки preset-комбинации `simpleOption`.

Старый `getPublicImageThumb()` сохранён.

## API `/api/get-ppv-price`

При смене опции в `simpleOption` фронтенд бьёт в `/api/get-ppv-price?productId=X&ppv=...`. Этот эндпоинт (`controllers/ApiController.php::actionGetPpvPrice`) теперь возвращает картинки через `ProductImage::getCardImage()`, то есть с тем же фоллбэком `lg → md → sm` что и при первом рендере страницы. За счёт этого слайдер `simpleOption` остаётся консистентным после смены опции.

## Что используется в темах

На текущий момент новая логика подключена только в `themes/mebelbazar/`. В каталоге:

- `themes/mebelbazar/views/category/_product.php` — `$model->catalogImage`.
- `themes/mebelbazar/views/category/_product_hit.php` — `$model->catalogImage`.

На странице товара:

- `themes/mebelbazar/views/product/index.php` — основной Owl-слайдер и миниатюры используют `$i->cardImage` (на каждом `ProductImage`).
- `themes/mebelbazar/views/product/simpleOption.php` — Splide-слайдеры берут `$model->ppvPresetImagesCdn`.
- `themes/mebelbazar/views/product/_complectation.php` — фон для каждого `subProduct` — `$i->cardImage`.
- `themes/mebelbazar/views/product/_actions.php` и `_modal-quick-buy.php` — `data-image` кнопок «В корзину»/quick-buy = `$model->catalogImage`. Это значение уезжает в `localStorage.larekCart`, потом — в `Purchase.cart` как снапшот для cart-detail и для письма.

В корзине / cart-detail:

- `controllers/CartController.php::actionIndex` — массив `$products[].image` теперь = `$model->getCatalogImage()`.
- `views/cart/_product.php` — рендер из `$product['image']`.
- `views/cart/_detail_product.php` — снапшот `Purchase.cart`. Если `image` пустой или плейсхолдер (`placehold.jp` / `placeholder.com`), рендер делает дозапрос `Product::findOne($model->id)->getCatalogImage()`. Это нужно для исторических заказов и заказов из устаревшего `localStorage`.

В email о заказе (`mail/purchase.php`, `mail/purchase-admin.php`) URL картинки больше не клеится безусловно: если `image` уже абсолютный (`http(s)://...`, в т.ч. CDN URL), используется как есть; иначе (старый относительный путь) — собирается через домен магазина.

## Поведение fallback

```
getCatalogImage() → CDN sm? → нет? → getPublicImageThumb() (старый)
getCardImage()    → CDN lg? → md? → sm? → нет? → getPublicImageFull() (старый)
                                              (или getPublicImageThumb() для SubProduct/Product)
```

Это значит:

- если CDN-домен не задан в теме — все шаблоны продолжат работать через старую логику без изменений;
- если у конкретного товара нет строк в `productImageSizes` (или нужного `sizeAlias`) — отдаётся старая картинка;
- если у товара нет `ProductImage` вовсе — отдаётся плейсхолдер `placehold.jp`.

## Как добавить CDN другой теме

1. В `commands/ThemeController.php` для нужной темы прописать `'cdn' => 'img.example.ru'`.
2. Запустить `php yii theme <name>` (на проде — внутри докер-контейнера `php74_mebel`).
3. В `config/params.php` появится строка `"cdn" => "img.example.ru"`.
4. В `views` соответствующей темы заменить:
    - `$model->publicImageThumb` → `$model->catalogImage` (каталог);
    - `$i->publicImageFull` / `$i->publicImageThumb` → `$i->cardImage` (страница товара);
    - `$model->ppvPresetImages` → `$model->ppvPresetImagesCdn` (для `simpleOption`).
5. На сервере, где работает CDN, должны быть залиты файлы из `productImageSizes.filename`. Доливка картинок и заполнение `productImageSizes` — отдельная задача (см. `commands/ImageController.php`).

## Диагностика «битых» картинок: `image/check-failed`

### Что такое `productImage.isExist = 0`

Внешний воркер обработки картинок (скачивает оригинал → ресайзит `sm/md/lg/xl` → заливает на CDN → POST-ит размеры в `productImageSizes` через `api_v2`) при неудаче помечает картинку `isExist = 0` (эндпоинт `api_v2/product-image/set-is-exist`). Такие картинки:

- **не имеют** ни одной строки в `productImageSizes` → на CDN их нет;
- **исключены** из основной очереди `api_v2/product-image/without-sizes` (она фильтрует `isExist = 1`), поэтому сами не восстановятся;
- попадают только в `api_v2/product-image/failed` — для ручного повторного прогона.

На витрине такая картинка отдаётся через fallback: `getCardImage()`/`getCatalogImage()` не находят CDN-размер → откатываются на локальный файл → если и его нет, отдаётся плейсхолдер (`placehold.jp` / `placeholder.com`).

Важно: `isExist = 0` **не значит, что оригинал утерян**. Часто это ложный сбой воркера (таймаут, сетевая ошибка, падение на конкретном шаге), а оригинал на источнике жив и ретрай `/failed` его дообработает. Команда `image/check-failed` как раз помогает отличить одно от другого.

### Команда

```bash
php yii image/check-failed [опции]
# на проде — внутри контейнера:
docker exec php74_mebel php yii image/check-failed --activeOnly=1
```

Реализация — `commands/ImageController.php::actionCheckFailed`. Команда **только читает БД** и делает HEAD-запросы к источнику; ничего не заливает и не ресайзит (кроме опционального `--markRetry`, см. ниже). Для каждой картинки с `isExist = 0` она проверяет доступность оригинала по URL `<host><productImage.image>` и классифицирует:

- **Доступны (ретрай)** — HTTP 2xx: оригинал жив, поможет повторный прогон воркера по `/failed`.
- **Утеряны (404/410)** — файла на источнике нет: нужна перезаливка вручную или чистка записи.
- **Ошибки (сеть/прочее)** — таймаут, DNS, 5xx и т.п.: стоит перепроверить позже.

### Опции

| Опция | По умолчанию | Назначение |
|---|---|---|
| `--productId=` | — | проверить только один товар (по `product.id`) |
| `--activeOnly=` | `0` | `1` — только активные и не удалённые товары (`active=1, isDelete=0`) |
| `--host=` | `https://mebelidomanet.ru` | базовый URL источника оригиналов |
| `--limit=` | `0` (все) | ограничить число проверяемых картинок |
| `--delay=` | `150` | пауза между запросами, мс (анти-троттлинг источника) |
| `--list=` | `0` | `1` — пофайловый вывод, иначе только сводка |
| `--markRetry=` | `0` | `1` — вернуть `isExist=1` доступным картинкам, т.е. пере-поставить их в очередь воркера (`/without-sizes` подхватит как «без размеров») |

### Типовые сценарии

```bash
# аудит по живой витрине — сколько битых и сколько из них реально потеряно
docker exec php74_mebel php yii image/check-failed --activeOnly=1

# детально по конкретному товару
docker exec php74_mebel php yii image/check-failed --productId=28046 --list=1

# проверить И сразу пере-поставить доступные в очередь на переобработку
docker exec php74_mebel php yii image/check-failed --activeOnly=1 --markRetry=1
```

`--markRetry=1` только меняет флаг `isExist` (0 → 1) у доступных картинок — реальную доливку размеров на CDN по-прежнему делает внешний воркер, когда в следующий раз опросит `/without-sizes`.

## Переобработка PNG (чёрная прозрачность → белый фон)

JPEG не поддерживает альфа-канал: воркер (`mebel-images`) при ресайзе PNG→JPEG раньше композитил прозрачность на **чёрный** фон (не было `.flatten()`), поэтому у прозрачных PNG все jpg-ресайзы (`sm/md/lg/xl`) получались с чёрными зонами. Оригинал (png) при этом цел. Фикс — `.flatten({white})` в резайзере воркера; чинит все **новые** обработки. Уже зачернённые картинки перегенерирует отдельный режим `RETRY_MODE=repng` (см. `mebel-images/docs/20-repng-reprocessing.md`).

Для этого режима в `api_v2` добавлены эндпоинты:

- `GET /api_v2/product-image/png-originals?limit=&offset=&includeTotal=` (+ `sub-product-image` аналог) — отдаёт `productImage`/`subProduct` с `original.format='png'`, `isExist=1`. Воркер идёт по набору **offset-пагинацией** (эти строки не выпадают после обработки, т.к. оригинал остаётся png). `ProductImageController::actionPngOriginals`, `SubProductImageController::actionPngOriginals`.
- `POST /api_v2/product-image-sizes/replace` (+ `sub-product-image-sizes` аналог) — атомарный upsert jpg-ресайзов в транзакции (`delete` по `(productImageId, sizeAlias)` + `insert`), чтобы витрина не мигнула плейсхолдером. Строку `original` **не трогает** (guard). Тело: `{ productImageId|subProductId, sizes:[{sizeAlias,width,height,filename,format,fileSize,hash}] }`. `ProductImageSizesController::actionReplace`, `SubProductImageSizesController::actionReplace`.

Старые чёрные объекты на S3 (имя = md5 старого содержимого) воркер удаляет сам после успешной замены; повторный прогон идемпотентен.

## Что осталось на старой логике

- `Category`, `Factory`, `Page`, `Review` и прочие сущности с собственным полем `image` — к `productImageSizes`/`subProductImageSizes` отношения не имеют, отображаются как раньше (через `web/uploads/<size>/...`).
- Картинки опций (`productPropertyValue.image`, `option.image`) — отдельные превью-файлы, не связаны с CDN-логикой.
- API-эндпоинты `actionProduct` (`/api/product`) и `actionGetProductById` (`/api/get-product-by-id`) на момент миграции из фронтенда не вызываются — оставлены без изменений.
