Skip to content

ADR-006: Обработка файлов и изображений

Дата: 2026-01-04
Статус: Принято
Авторы: Backend Team


Контекст

Проект требует работы с изображениями:

  • Фотографии товаров (основная функция)
  • Аватары пользователей
  • Логотипы марок автомобилей (справочник)
  • Документы для верификации бизнес-аккаунтов

Требования

Согласно общее.md:

  • Хранение: Selectel Object Storage (S3-совместимый)
  • До 10 фотографий на товар
  • Адаптивные изображения для разных устройств
  • CDN для быстрой доставки

Характеристики нагрузки

ТипКол-во/товарРазмер оригиналаВарианты
Фото товара1-10до 10 MBthumb, medium, large
Аватар1до 5 MBsmall, medium
Логотип марки1до 1 MBoriginal

Решение

Архитектура

┌─────────────────────────────────────────────────────────────┐
│                        КЛИЕНТ                                │
│  ┌─────────────────┐                                        │
│  │  Загрузка файла │                                        │
│  │  (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

Связанные решения