- добавлен shared config presets, custom transforms и allowlist hosts - реализованы Backend endpoints для assets, presets и variants - добавлена orchestration через PostgreSQL, RabbitMQ, S3 и worker - обновлён Gateway read-through flow с L1 cache и корректным Vary: Accept - добавлена миграция resize_mode для variants lookup - обновлены dev scripts, env template, lockfile и документация
327 lines
8.9 KiB
Markdown
327 lines
8.9 KiB
Markdown
# Черновик Backend Контракта
|
||
|
||
Это черновик бизнес-контракта для NestJS backend. Сейчас реализованы system endpoints, Swagger, `POST /api/assets` и internal image ensure MVP.
|
||
|
||
## System
|
||
|
||
```text
|
||
GET /api/health
|
||
GET /docs
|
||
GET /docs-json
|
||
```
|
||
|
||
NestJS backend отдаёт JSON, metadata, statuses и URLs. Public image origin находится в Fastify Gateway. Backend владеет PostgreSQL, S3 orchestration и RabbitMQ jobs.
|
||
|
||
## Internal Image Ensure
|
||
|
||
Этот internal endpoint вызывается Gateway на L1 miss. Gateway не ходит в DB/S3 напрямую.
|
||
|
||
```text
|
||
POST /api/internal/images/ensure
|
||
```
|
||
|
||
Текущий статус реализации - MVP реализован: Backend создаёт/переиспользует variant row, публикует RabbitMQ job, ждёт `ready` до timeout и возвращает image bytes из S3.
|
||
|
||
Request body:
|
||
|
||
```json
|
||
{
|
||
"assetId": "asset_123",
|
||
"version": 4,
|
||
"preset": "card",
|
||
"width": 640,
|
||
"height": 0,
|
||
"quality": 80,
|
||
"requestedFormat": "auto",
|
||
"resize": "fit",
|
||
"format": "webp"
|
||
}
|
||
```
|
||
|
||
Query params:
|
||
|
||
| Param | Описание |
|
||
|---|---|
|
||
| `w` | целевая ширина; обязательна для responsive presets и custom, запрещена/фиксирована для fixed presets |
|
||
| `h` | целевая высота; используется для custom, для fixed preset берётся из config |
|
||
| `q` | качество; если не передано, берётся из preset, иначе должно входить в allowlist preset |
|
||
| `f` | `auto`, `avif`, `webp`, `jpg`; для Next/image по умолчанию `auto` |
|
||
| `fit` | `fit` или `fill`; используется только для custom transforms |
|
||
|
||
Responsibilities:
|
||
|
||
- проверить `assetId` и статический preset/custom transform config;
|
||
- нормализовать width, height, resize, quality и format;
|
||
- вычислить deterministic `variantHash`;
|
||
- проверить PostgreSQL и S3;
|
||
- если variant готов в S3, вернуть bytes или stream metadata для Gateway;
|
||
- если variant отсутствует, создать/переиспользовать variant row;
|
||
- поставить `generate-variant` job в RabbitMQ;
|
||
- дождаться `ready` до timeout, чтобы первый `next/image` request мог получить картинку;
|
||
- вернуть image response или metadata для Gateway.
|
||
|
||
Response headers:
|
||
|
||
```http
|
||
Content-Type: image/avif | image/webp | image/jpeg
|
||
Cache-Control: public, max-age=31536000, immutable
|
||
Vary: Accept
|
||
ETag: "..."
|
||
```
|
||
|
||
Ошибки:
|
||
|
||
| Status | Когда |
|
||
|---|---|
|
||
| `400` | некорректные query params |
|
||
| `404` | asset или preset не найден |
|
||
| `502` | external imgproxy недоступен или S3 object отсутствует после `ready` |
|
||
| `504` | generation не завершилась до `IMAGE_ENSURE_WAIT_MS` |
|
||
|
||
Public endpoint реализуется в Fastify Gateway, internal ensure endpoint - в Backend:
|
||
|
||
```text
|
||
client -> CDN -> Gateway /images/* -> Backend ensure -> RabbitMQ -> Worker -> imgproxy -> S3
|
||
```
|
||
|
||
Важно: `/images/*` не должен жить в NestJS `/api`. Gateway остаётся public image origin, Backend остаётся управлением процессов.
|
||
Public image URL versioned: `/images/{assetId}/v{version}/{preset}?w={width}&q={quality}&f=auto`.
|
||
|
||
Примеры:
|
||
|
||
```text
|
||
/images/asset_demo/v1/card?w=640&q=80&f=auto
|
||
/images/asset_demo/v1/avatar?f=auto
|
||
/images/asset_demo/v1/custom?w=777&h=333&q=72&fit=fill&f=webp
|
||
```
|
||
|
||
## Allowed Hosts
|
||
|
||
```text
|
||
GET /allowed-hosts
|
||
POST /allowed-hosts
|
||
PATCH /allowed-hosts/:id
|
||
DELETE /allowed-hosts/:id
|
||
```
|
||
|
||
## Assets
|
||
|
||
```text
|
||
POST /assets
|
||
GET /assets
|
||
GET /assets/:publicId
|
||
GET /assets/:publicId/variants
|
||
POST /assets/:publicId/variants
|
||
DELETE /assets/:id
|
||
```
|
||
|
||
Сейчас реализованы `POST /assets`, `GET /assets`, `GET /assets/:publicId`, `GET /assets/:publicId/variants`, `POST /assets/:publicId/variants`.
|
||
|
||
`POST /assets` request:
|
||
|
||
```json
|
||
{
|
||
"sourceUrl": "https://storage.yandexcloud.net/shared1318/img/1.jpg",
|
||
"publicId": "asset_demo"
|
||
}
|
||
```
|
||
|
||
Response:
|
||
|
||
```json
|
||
{
|
||
"id": "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66",
|
||
"publicId": "asset_demo",
|
||
"version": 1,
|
||
"sourceHost": "storage.yandexcloud.net",
|
||
"imageBasePath": "/images/asset_demo/v1/card"
|
||
}
|
||
```
|
||
|
||
Responsibilities:
|
||
|
||
- validate source URL;
|
||
- check mock allowlist `SOURCE_ALLOWED_HOSTS`, если `SOURCE_HOST_ALLOW_ALL=false`;
|
||
- создать `image_assets` row;
|
||
- создать `image_asset_versions` row версии `1`;
|
||
- optionally save original to S3 later.
|
||
|
||
`GET /assets` response:
|
||
|
||
```json
|
||
{
|
||
"assets": [
|
||
{
|
||
"id": "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66",
|
||
"publicId": "asset_demo",
|
||
"currentVersion": 1,
|
||
"status": "active",
|
||
"sourceUrl": "https://storage.yandexcloud.net/shared1318/img/1.jpg",
|
||
"sourceHost": "storage.yandexcloud.net",
|
||
"createdAt": "2026-05-05T12:00:00.000Z",
|
||
"updatedAt": "2026-05-05T12:00:00.000Z"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
`GET /assets/:publicId/variants` возвращает rows из `image_variants` с public Gateway URL, S3 key и status.
|
||
|
||
## Variants
|
||
|
||
```text
|
||
GET /assets/:publicId/variants
|
||
POST /assets/:publicId/variants
|
||
POST /variants/:id/regenerate
|
||
DELETE /variants/:id
|
||
```
|
||
|
||
`POST /assets/:publicId/variants` request:
|
||
|
||
```json
|
||
{
|
||
"preset": "card",
|
||
"mode": "single",
|
||
"format": "webp",
|
||
"width": 640,
|
||
"quality": 80
|
||
}
|
||
```
|
||
|
||
Family generation:
|
||
|
||
```json
|
||
{
|
||
"preset": "card",
|
||
"mode": "family"
|
||
}
|
||
```
|
||
|
||
Для `family` Backend создаёт набор variants по разрешённым widths/formats static preset и ставит RabbitMQ jobs. Endpoint не ждёт image bytes, в отличие от internal ensure.
|
||
|
||
Response:
|
||
|
||
```json
|
||
{
|
||
"publicId": "asset_demo",
|
||
"version": 1,
|
||
"variants": [
|
||
{
|
||
"id": "variant_123",
|
||
"preset": "card",
|
||
"version": 1,
|
||
"width": 640,
|
||
"height": 0,
|
||
"resize": "fit",
|
||
"quality": 80,
|
||
"requestedFormat": "webp",
|
||
"format": "webp",
|
||
"status": "pending",
|
||
"url": "http://localhost:8888/images/asset_demo/v1/card?w=640&q=80&f=webp",
|
||
"s3Key": "variants/asset_demo/v1/abc.webp"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
## Presets
|
||
|
||
```text
|
||
GET /presets
|
||
```
|
||
|
||
Возвращает static presets, custom transform limits и mock allowlist source hosts.
|
||
|
||
```json
|
||
{
|
||
"presets": [
|
||
{
|
||
"name": "card",
|
||
"mode": "responsive",
|
||
"formats": ["avif", "webp", "jpg"],
|
||
"qualities": [75, 80],
|
||
"quality": 80,
|
||
"resize": "fit",
|
||
"widths": [320, 640, 960]
|
||
}
|
||
],
|
||
"custom": {
|
||
"enabled": true,
|
||
"formats": ["avif", "webp", "jpg", "png"],
|
||
"maxWidth": 4096,
|
||
"maxHeight": 4096,
|
||
"quality": 80
|
||
},
|
||
"allowedSourceHosts": ["storage.yandexcloud.net"]
|
||
}
|
||
```
|
||
|
||
## Image URLs For UI
|
||
|
||
Для ручного UI можно добавить endpoint, который возвращает готовый набор URLs для `<picture>`/`srcset`. Для `next/image` основным контрактом остаётся custom loader из `docs/next-image-provider.md`.
|
||
|
||
```text
|
||
GET /assets/:id/picture?preset=card
|
||
```
|
||
|
||
Example response:
|
||
|
||
```json
|
||
{
|
||
"assetId": "asset_123",
|
||
"preset": "card",
|
||
"sources": [
|
||
{
|
||
"type": "image/avif",
|
||
"srcset": "http://localhost:8888/images/asset_123/v4/card?w=320&q=80&f=avif 320w, http://localhost:8888/images/asset_123/v4/card?w=640&q=80&f=avif 640w"
|
||
},
|
||
{
|
||
"type": "image/webp",
|
||
"srcset": "http://localhost:8888/images/asset_123/v4/card?w=320&q=80&f=webp 320w, http://localhost:8888/images/asset_123/v4/card?w=640&q=80&f=webp 640w"
|
||
}
|
||
],
|
||
"fallback": {
|
||
"src": "http://localhost:8888/images/asset_123/v4/card?w=640&q=80&f=jpg",
|
||
"width": 640,
|
||
"height": null
|
||
}
|
||
}
|
||
```
|
||
|
||
## Worker Lifecycle
|
||
|
||
Worker выполняет задачи из RabbitMQ. Задачи создаёт Backend.
|
||
|
||
RabbitMQ topology:
|
||
|
||
```text
|
||
exchange: image-platform.jobs
|
||
queue: image.generate-variant
|
||
dead-letter exchange: image-platform.jobs.dlx
|
||
dead-letter queue: image.generate-variant.dlq
|
||
```
|
||
|
||
Job payload должен быть минимальным:
|
||
|
||
```json
|
||
{
|
||
"jobId": "job_123",
|
||
"variantId": "variant_123"
|
||
}
|
||
```
|
||
|
||
Worker читает детали variant из PostgreSQL, вызывает imgproxy, пишет результат в S3 и обновляет status в PostgreSQL.
|
||
|
||
Если генерация тяжёлая или не успела завершиться до timeout, Backend может вернуть Gateway `504`, а job продолжит выполняться/retry по очереди.
|
||
|
||
PostgreSQL может выступить первой очередью:
|
||
|
||
```text
|
||
SELECT * FROM image_variants
|
||
WHERE status = 'pending'
|
||
FOR UPDATE SKIP LOCKED
|
||
LIMIT 1
|
||
```
|
||
|
||
PostgreSQL-backed очередь не используется как основной механизм: для jobs выбран RabbitMQ.
|