Appearance
ADR-006: Обработка файлов и изображений
Дата: 2026-01-04
Статус: Принято
Авторы: Backend Team
Контекст
Проект требует работы с изображениями:
- Фотографии товаров (основная функция)
- Аватары пользователей
- Логотипы марок автомобилей (справочник)
- Документы для верификации бизнес-аккаунтов
Требования
Согласно общее.md:
- Хранение: Selectel Object Storage (S3-совместимый)
- До 10 фотографий на товар
- Адаптивные изображения для разных устройств
- CDN для быстрой доставки
Характеристики нагрузки
| Тип | Кол-во/товар | Размер оригинала | Варианты |
|---|---|---|---|
| Фото товара | 1-10 | до 10 MB | thumb, medium, large |
| Аватар | 1 | до 5 MB | small, medium |
| Логотип марки | 1 | до 1 MB | original |
Решение
Архитектура
┌─────────────────────────────────────────────────────────────┐
│ КЛИЕНТ │
│ ┌─────────────────┐ │
│ │ Загрузка файла │ │
│ │ (multipart) │ │
│ └────────┬────────┘ │
└───────────┼─────────────────────────────────────────────────┘
│ POST /vendor/products/:id/images
▼
┌─────────────────────────────────────────────────────────────┐
│ BACKEND (Medusa.js) │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ 1. Валидация (тип, размер) ││
│ │ 2. Сохранение оригинала в S3 ││
│ │ 3. Публикация задачи в очередь ││
│ └─────────────────────────────────────────────────────────┘│
└──────────────────────────┬──────────────────────────────────┘
│
┌──────────────┴──────────────┐
▼ ▼
┌─────────────────────┐ ┌─────────────────────────────────┐
│ SELECTEL S3 │ │ BACKGROUND WORKER │
│ ┌───────────────┐ │ │ ┌─────────────────────────────┐│
│ │ originals/ │ │ │ │ 1. Скачать оригинал ││
│ │ thumbnails/ │◄─┼──────┼──│ 2. Resize (sharp) ││
│ │ medium/ │ │ │ │ 3. Optimize (webp/avif) ││
│ │ large/ │ │ │ │ 4. Загрузить варианты ││
│ └───────────────┘ │ │ │ 5. Обновить БД ││
└─────────────────────┘ │ └─────────────────────────────┘│
│ └─────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ CDN (Selectel) │
│ https://cdn.partizap.ru/images/... │
└─────────────────────────────────────────────────────────────┘Конфигурация S3
Структура бакета
autoparts-media/
├── products/
│ ├── originals/
│ │ └── {product_id}/{uuid}.{ext}
│ ├── thumbnails/ # 150x150
│ │ └── {product_id}/{uuid}.webp
│ ├── medium/ # 600x600
│ │ └── {product_id}/{uuid}.webp
│ └── large/ # 1200x1200
│ └── {product_id}/{uuid}.webp
├── avatars/
│ ├── small/ # 64x64
│ │ └── {user_id}.webp
│ └── medium/ # 256x256
│ └── {user_id}.webp
├── car-logos/
│ └── {make_slug}.png
└── documents/ # Приватные документы
└── {user_id}/{uuid}.pdfПрава доступа
| Папка | Доступ | Причина |
|---|---|---|
products/* | public-read | Изображения товаров |
avatars/* | public-read | Аватары пользователей |
car-logos/* | public-read | Справочник |
documents/* | private | Документы верификации |
Реализация
Конфигурация S3 клиента
typescript
// src/config/s3.ts
import { S3Client } from '@aws-sdk/client-s3';
export const s3Client = new S3Client({
endpoint: process.env.S3_URL, // https://s3.ru-1.storage.selcloud.ru
region: process.env.S3_REGION, // ru-1
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
forcePathStyle: true, // Для совместимости с Selectel
});
export const S3_CONFIG = {
bucket: process.env.S3_BUCKET!, // autoparts-media
cdnUrl: process.env.CDN_URL!, // https://cdn.partizap.ru
};Сервис загрузки изображений
typescript
// src/services/file/image-upload.service.ts
import { PutObjectCommand } from '@aws-sdk/client-s3';
import sharp from 'sharp';
import { v4 as uuidv4 } from 'uuid';
interface ImageVariant {
name: string;
width: number;
height: number;
quality: number;
}
const PRODUCT_VARIANTS: ImageVariant[] = [
{ name: 'thumbnail', width: 150, height: 150, quality: 80 },
{ name: 'medium', width: 600, height: 600, quality: 85 },
{ name: 'large', width: 1200, height: 1200, quality: 90 },
];
export class ImageUploadService {
async uploadProductImage(
productId: string,
file: Buffer,
mimeType: string
): Promise<ProductImage> {
// 1. Валидация
this.validateImage(file, mimeType);
// 2. Генерация UUID для файла
const imageId = uuidv4();
const ext = this.getExtension(mimeType);
// 3. Загрузка оригинала
const originalKey = `products/originals/${productId}/${imageId}.${ext}`;
await this.uploadToS3(originalKey, file, mimeType);
// 4. Создание записи в БД (со статусом processing)
const image = await this.createImageRecord(productId, imageId, originalKey);
// 5. Публикация задачи на обработку
await this.queueService.add('process-image', {
imageId: image.id,
productId,
originalKey,
});
return image;
}
private validateImage(file: Buffer, mimeType: string): void {
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
if (!ALLOWED_TYPES.includes(mimeType)) {
throw new ValidationError('Недопустимый формат изображения');
}
if (file.length > MAX_SIZE) {
throw new ValidationError('Размер файла превышает 10 MB');
}
}
private async uploadToS3(
key: string,
body: Buffer,
contentType: string
): Promise<void> {
await s3Client.send(new PutObjectCommand({
Bucket: S3_CONFIG.bucket,
Key: key,
Body: body,
ContentType: contentType,
ACL: 'public-read',
CacheControl: 'public, max-age=31536000', // 1 год
}));
}
}Background Worker для обработки
typescript
// src/workers/image-processor.worker.ts
import { Job } from 'bullmq';
import sharp from 'sharp';
import { GetObjectCommand } from '@aws-sdk/client-s3';
export async function processImageJob(job: Job): Promise<void> {
const { imageId, productId, originalKey } = job.data;
// 1. Скачать оригинал
const original = await downloadFromS3(originalKey);
// 2. Создать варианты
for (const variant of PRODUCT_VARIANTS) {
await job.updateProgress((PRODUCT_VARIANTS.indexOf(variant) + 1) / PRODUCT_VARIANTS.length * 100);
// Resize с сохранением пропорций
const processed = await sharp(original)
.resize(variant.width, variant.height, {
fit: 'cover',
position: 'center',
})
.webp({ quality: variant.quality })
.toBuffer();
// Загрузить вариант
const variantKey = `products/${variant.name}/${productId}/${imageId}.webp`;
await uploadToS3(variantKey, processed, 'image/webp');
}
// 3. Обновить статус в БД
await updateImageStatus(imageId, 'ready', {
thumbnail: `${S3_CONFIG.cdnUrl}/products/thumbnail/${productId}/${imageId}.webp`,
medium: `${S3_CONFIG.cdnUrl}/products/medium/${productId}/${imageId}.webp`,
large: `${S3_CONFIG.cdnUrl}/products/large/${productId}/${imageId}.webp`,
});
}
async function downloadFromS3(key: string): Promise<Buffer> {
const response = await s3Client.send(new GetObjectCommand({
Bucket: S3_CONFIG.bucket,
Key: key,
}));
return Buffer.from(await response.Body!.transformToByteArray());
}Модель данных
sql
-- Таблица изображений товаров
CREATE TABLE product_images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'processing'
CHECK (status IN ('processing', 'ready', 'error')),
-- Ключи в S3
original_key VARCHAR(500) NOT NULL,
thumbnail_url VARCHAR(500),
medium_url VARCHAR(500),
large_url VARCHAR(500),
-- Метаданные
original_filename VARCHAR(255),
mime_type VARCHAR(50),
file_size INTEGER,
width INTEGER,
height INTEGER,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP,
UNIQUE(product_id, position)
);
CREATE INDEX idx_product_images_product ON product_images(product_id);
CREATE INDEX idx_product_images_status ON product_images(status) WHERE status = 'processing';API Endpoints
Загрузка изображения
typescript
// POST /vendor/products/:productId/images
// Content-Type: multipart/form-data
// Ответ 201 Created
{
"data": {
"id": "img_01H...",
"status": "processing",
"position": 0,
"urls": null // Будут доступны после обработки
}
}Получение изображений товара
typescript
// GET /store/products/:productId/images
// Ответ 200 OK
{
"data": [
{
"id": "img_01H...",
"position": 0,
"urls": {
"thumbnail": "https://cdn.partizap.ru/products/thumbnail/prod_01/img_01.webp",
"medium": "https://cdn.partizap.ru/products/medium/prod_01/img_01.webp",
"large": "https://cdn.partizap.ru/products/large/prod_01/img_01.webp"
}
}
]
}Изменение порядка
typescript
// PUT /vendor/products/:productId/images/reorder
{
"order": ["img_03", "img_01", "img_02"]
}Удаление изображения
typescript
// DELETE /vendor/products/:productId/images/:imageId
// Ответ 204 No Content
// Фоновая задача удаляет файлы из S3Оптимизации
1. Lazy Loading на фронтенде
vue
<!-- frontend/components/ProductGallery.vue -->
<template>
<div class="gallery">
<img
v-for="image in images"
:key="image.id"
:src="image.urls.thumbnail"
:data-src="image.urls.large"
loading="lazy"
@click="openLightbox(image)"
/>
</div>
</template>2. Responsive Images
vue
<picture>
<source
media="(max-width: 640px)"
:srcset="image.urls.thumbnail"
/>
<source
media="(max-width: 1024px)"
:srcset="image.urls.medium"
/>
<img
:src="image.urls.large"
:alt="product.title"
/>
</picture>3. Blurhash для плейсхолдеров
typescript
// При обработке изображения
import { encode } from 'blurhash';
const blurhash = await generateBlurhash(imageBuffer);
// Сохранить в БД: "LEHV6nWB2yk8pyo0adR*.7kCMdnj"
// На фронте использовать как placeholderУдаление файлов
При удалении товара
typescript
// src/subscribers/product-deletion.subscriber.ts
export async function handleProductDeleted({ data }: { data: { id: string } }) {
// Добавляем задачу на удаление файлов
await queueService.add('cleanup-product-files', {
productId: data.id,
});
}
// Worker
export async function cleanupProductFiles(job: Job) {
const { productId } = job.data;
// Получить все ключи
const images = await getProductImages(productId);
// Удалить из S3
for (const image of images) {
await deleteFromS3(`products/originals/${productId}/${image.id}.*`);
await deleteFromS3(`products/thumbnail/${productId}/${image.id}.webp`);
await deleteFromS3(`products/medium/${productId}/${image.id}.webp`);
await deleteFromS3(`products/large/${productId}/${image.id}.webp`);
}
}Безопасность
Валидация загружаемых файлов
typescript
import fileType from 'file-type';
async function validateUploadedFile(buffer: Buffer): Promise<void> {
// 1. Проверка magic bytes (не доверять Content-Type)
const type = await fileType.fromBuffer(buffer);
if (!type || !ALLOWED_MIME_TYPES.includes(type.mime)) {
throw new ValidationError('Недопустимый тип файла');
}
// 2. Проверка что это действительно изображение
try {
const metadata = await sharp(buffer).metadata();
if (!metadata.width || !metadata.height) {
throw new Error('Invalid image');
}
// 3. Защита от zip-bombs
if (metadata.width > 10000 || metadata.height > 10000) {
throw new ValidationError('Изображение слишком большое');
}
} catch (error) {
throw new ValidationError('Не удалось обработать изображение');
}
}Приватные документы
typescript
// Для документов верификации — signed URLs
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
async function getPrivateDocumentUrl(key: string): Promise<string> {
const command = new GetObjectCommand({
Bucket: S3_CONFIG.bucket,
Key: key,
});
// URL действителен 15 минут
return getSignedUrl(s3Client, command, { expiresIn: 900 });
}Последствия
Положительные
- Производительность: WebP сжатие, CDN, lazy loading
- Масштабируемость: S3 бесконечное хранилище
- UX: мгновенная загрузка, фоновая обработка
- SEO: оптимизированные изображения
Отрицательные
- Сложность: асинхронная обработка
- Стоимость: S3 + CDN + CPU для обработки
- Задержка: изображения не сразу доступны
Митигация
| Проблема | Решение |
|---|---|
| Долгая обработка | Показывать оригинал до готовности вариантов |
| Ошибка обработки | Retry с exponential backoff, fallback на оригинал |
| Много мелких файлов | Batch операции для S3 |
Связанные решения
- ADR-007: Background Jobs — очередь обработки
- ADR-002: REST API — эндпоинты загрузки