Skip to main content

Static Content URL Strategy

Стратегия хранения и формирования URL статического контента (изображения предметов, кейсов, баннеров, аватаров).

Core Principle

imageUrl в БД хранит полный абсолютный URL (https://static.goloot.online/images/...), а не относительный путь. Клиенты используют его напрямую в <img src={imageUrl}> без трансформации.


1. Summary

Проблема: Изображения хранятся на отдельном static-сервере (static.goloot.online), а используются в трёх независимых клиентах (TMA frontend, admin panel, backend API responses). Каждый клиент должен корректно отображать картинки.

Решение: Backend формирует полный URL при записи в БД (upload, seed). Клиенты используют imageUrl as-is.

Альтернатива (отклонена): Хранить относительный путь (/images/items/skin.webp) и добавлять домен на каждом клиенте. Отклонено — см. ADR ниже.


2. Архитектура

Потоки данных

Формирование URL

При записи в БД (upload route, seed):

// backend/src/domains/cases/routes/admin-items-upload.routes.ts
const imageUrl = process.env.STATIC_URL
? `${process.env.STATIC_URL}${relativePath}`
: relativePath;

При чтении из БД (frontend, admin):

// Никакой трансформации — используется напрямую
<img src={item.imageUrl} />

URL Builders (SSOT)

Все builders определены в одном месте: backend/src/common/constants/url.constants.ts

BuilderРезультат
buildItemImageUrl('skin.webp')https://static.goloot.online/images/items/skin.webp
buildCaseImageUrl('case.webp')https://static.goloot.online/images/cases/case.webp
buildAvatarUrl('123456')https://static.goloot.online/images/avatars/123456.jpg
buildBannerUrl('ad.webp')https://static.goloot.online/images/banners/ad.webp

3. ADR: Полный URL vs Относительный путь

Контекст

Изображения обслуживает отдельный сервис static-nginx на домене static.goloot.online. Клиенты (TMA, admin) работают на своих доменах (goloot.online, admin.goloot.online).

Решение: Полный URL в БД

Причины

1. Множество потребителей без общего домена

imageUrl используется в 5+ контекстах, каждый на своём домене:

ПотребительДоменГде используется
TMA Frontendgoloot.onlineИнвентарь, кейсы, крафт, квесты
Admin Paneladmin.goloot.onlineУправление предметами, кейсами
Backend APIapi.goloot.onlineFeed, уведомления, OpenGraph
Telegram BotInline-превью

Если хранить относительный путь /images/items/skin.webp, каждый потребитель должен знать STATIC_URL и трансформировать URL. Это:

  • Дублирование логики (DRY violation)
  • Риск рассинхронизации между клиентами
  • Дополнительный env (VITE_STATIC_URL) на каждом клиенте только для трансформации imageUrl

2. Backend — единая точка записи

Все imageUrl попадают в БД через backend (upload routes, seed, парсеры). Backend всегда имеет STATIC_URL в env. Логично собирать полный URL один раз при записи, а не при каждом чтении.

3. Прозрачность при дебаге

Полный URL в БД = сразу видно куда указывает картинка. Не нужно мысленно подставлять STATIC_URL.

Альтернативы (отклонены)

Относительный путь + трансформация на клиенте:

  • Каждый клиент добавляет STATIC_URL — дублирование
  • При добавлении нового клиента (например, Telegram bot) — ещё одно место для трансформации
  • Забыл добавить — картинки не грузятся (реальный баг)

Относительный путь + трансформация на backend при чтении:

  • Нужен mapper/middleware для каждого endpoint, возвращающего imageUrl
  • Легко пропустить — endpoint без трансформации = сломанные картинки
  • Пример: quiz repository уже делает это вручную (normalizeImageUrl) — усложняет код

Последствия

Плюсы:

  • Клиенты не знают о STATIC_URL — просто src={imageUrl}
  • Один источник формирования URL — upload routes + seeds
  • Нет риска забыть трансформацию в новом endpoint/клиенте

Минусы:

  • При смене домена статики — нужна миграция данных в БД (UPDATE items SET "imageUrl" = REPLACE(...))
  • Seed файлы зависят от STATIC_URL env variable
Смена домена статики

При миграции на другой домен (например, CDN) потребуется SQL-миграция:

UPDATE items SET "imageUrl" = REPLACE("imageUrl", 'https://old-domain', 'https://new-domain');
UPDATE quizzes SET "imageUrl" = REPLACE("imageUrl", 'https://old-domain', 'https://new-domain');
-- и другие таблицы с imageUrl

Это разовая операция, а не ежедневная проблема.


4. Исключение: Quizzes

Quiz imageUrl исторически хранится как относительный путь (/images/quizzes/...). Repository нормализует при чтении:

// quiz.repository.ts
if (quiz.imageUrl.startsWith('/images/')) {
return { ...quiz, imageUrl: `${ENV.STATIC_URL}${quiz.imageUrl}` };
}

Это legacy-паттерн. Новые сущности должны следовать основному паттерну — полный URL при записи.


5. Правила для разработки

СитуацияЧто делать
Новый upload routeimageUrl = STATIC_URL + relativePath → сохранить в БД
Новый seed файлimageUrl = STATIC_URL + path (с fallback)
Новый frontend компонент<img src={item.imageUrl} /> — без трансформации
Новый backend endpointВозвращать imageUrl из БД as-is
Тест / fixtureИспользовать полный URL: https://example.com/item.png

6. Связанные файлы

ФайлНазначение
backend/src/common/constants/url.constants.tsSSOT: IMAGE_PATHS + URL_BUILDERS
frontend/src/config/urls.tsFrontend URL builders (для non-DB assets)
backend/src/domains/cases/routes/admin-items-upload.routes.tsUpload route — формирует полный URL
backend/src/domains/quizzes/repositories/quiz.repository.tsИсключение: normalizeImageUrl для quizzes
backend/prisma/seed/seed-resources.tsSeed — использует STATIC_URL