Skip to main content

Static Content URL Strategy

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

Core Principle

imageUrl в БД хранит относительный путь (/images/items/..., /images/cases/...), а не полный URL. Upload routes и seed файлы сохраняют relative path. Полный URL формируется автоматически на выходе из API через единственный механизм — Fastify preSerialization hook.

Единый механизм нормализации: Централизованный preSerialization hook prepend-ит STATIC_URL к любой строке начинающейся с /images/ во всех JSON-ответах. Это единственная точка формирования полного URL — не safety net, а основной механизм.


1. Summary

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

Решение: Relative Path + Centralized Hook. Все сущности хранят относительные пути (/images/...) в БД. Fastify preSerialization hook — единственный механизм нормализации — автоматически prepend-ит STATIC_URL ко всем relative paths при выходе из API.

Альтернатива (отклонена): Добавлять домен на каждом клиенте. Отклонено — см. ADR ниже.


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

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

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

При записи в БД — upload routes и seeds сохраняют относительный путь:

// backend/src/domains/cases/routes/admin-items-upload.routes.ts
const imageUrl = relativePath;

// backend/src/domains/cases/routes/admin-cases-upload.routes.ts
const imageUrl = `${IMAGE_PATHS.CASES}/${fileName}`;

// backend/src/domains/admin/routes/admin-uploads.routes.ts (banners, push)
const imageUrl = `${IMAGE_PATHS.BANNERS}/${fileName}`;

При чтении из БД — hook нормализует автоматически (единственный механизм):

// backend/src/common/hooks/image-url.hook.ts
// Рекурсивно обходит JSON payload, prepend STATIC_URL к "/images/..." строкам
// Зарегистрирован в server.ts — покрывает ВСЕ JSON-ответы
server.addHook('preSerialization', async (_request, _reply, payload) => {
return normalizeImageUrls(payload);
});

На клиенте — без трансформации:

<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Не используется (0 вызовов в коде)
buildCaseImageUrl('case.webp')https://static.goloot.online/images/cases/case.webpНе используется (0 вызовов в коде)
buildBannerUrl('ad.webp')https://static.goloot.online/images/banners/ad.webpНе используется (0 вызовов в коде)
buildAvatarUrl('123456')/images/avatars/123456.jpg (relative)Активно используется (13 файлов, 7 доменов — см. ниже)
Dead Code

buildItemImageUrl, buildCaseImageUrl, buildBannerUrl определены, но имеют 0 вызовов в backend. Upload routes формируют relative path напрямую через IMAGE_PATHS константы. Эти builders — dead code, оставшийся от прежнего подхода с full URL при записи.

preValidation Hook (Inbound)

Симметричный registerInputImageUrlHook зарегистрирован в server.ts:223 как preValidation hook. Он strip-ит STATIC_URL prefix из входящих request bodies — гарантирует, что сервисы всегда получают relative paths, совпадающие с тем, что хранится в БД. Это нужно, когда admin panel отправляет full URL обратно на сервер (например, при редактировании сущности).

// backend/src/common/hooks/image-url.hook.ts
// Рекурсивно обходит request body, strip-ит STATIC_URL prefix из "${STATIC_URL}/images/..." строк
server.addHook('preValidation', async (request) => {
if (request.body) {
stripStaticUrlPrefix(request.body);
}
});

preSerialization Hook (Outbound)

Hook зарегистрирован в server.ts и обрабатывает все JSON-ответы:

СтрокаДействие
Начинается с /images/Prepend STATIC_URL → full URL
Начинается с http:// или https://Пропускается (уже full URL)
null / undefinedПропускается
Любая другая строкаПропускается

Обход рекурсивный — обрабатывает вложенные объекты, массивы, любую глубину.

SSE Exception

Endpoints использующие reply.raw.write() (SSE) обходят preSerialization pipeline. Feed SSE нормализует вручную через transform-event.utils.ts. Raffle SSE не содержит image URLs.


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

Контекст

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

Решение: Relative Path в БД + centralized hook

Причины

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

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

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

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

Все imageUrl попадают в БД через backend как relative paths. preSerialization hook гарантирует, что любой relative path будет нормализован при выходе из API — невозможно забыть.

3. Простота при смене домена

Relative path в БД = при смене домена статики достаточно обновить STATIC_URL env variable. SQL-миграция не требуется.

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

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

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

Ручной normalizeImageUrl() в каждом endpoint (устаревший подход):

  • Исторически использовался для quizzes и avatars (78 ручных вызовов в 29 файлах)
  • Легко пропустить endpoint — подтверждённый баг: admin raffle не нормализовал prizeImageUrl, фото приза не грузилось
  • Заменён centralized hook, ручные вызовы удалены

Последствия

Плюсы:

  • Клиенты не знают о STATIC_URL — просто src={imageUrl} (hook подставляет full URL)
  • Единый механизм нормализации — preSerialization hook покрывает все endpoints
  • Zero maintenance — новые endpoints автоматически покрываются
  • Простота миграции — при смене домена статики достаточно обновить STATIC_URL env variable, SQL-миграция не требуется

Минусы:

  • SSE endpoints обходят hook — требуется ручная нормализация (см. секцию SSE Exception)
Смена домена статики

Все сущности хранят relative paths — при миграции на другой домен (например, CDN) достаточно обновить STATIC_URL env variable. SQL-миграция не требуется — hook автоматически подхватит новый домен.


4. Примеры хранения relative paths

Все сущности хранят относительные пути в БД. Hook нормализует их автоматически при выходе из API.

СущностьПример значения в БДИсточник записи
Items/images/items/skins/ak47.webpadmin-items-upload.routes.ts
Cases/images/cases/case.webpadmin-cases-upload.routes.ts
Banners/images/banners/ad.webpadmin-uploads.routes.ts
Quizzes/images/quizzes/quiz.webpadmin-quizzes-upload.routes.ts
Avatars/images/avatars/123456.jpg?v=1234avatar-cache.service.ts
Buffs (seed)/images/items/buffs/xp.webpseed-buffs.ts
Resources (seed)/images/items/resources/wood.webpseed-resources.ts

Avatars

User.avatarUrl хранится как /images/avatars/{telegramId}.jpg?v={mtime}. Аватары формируются асинхронно через avatar-cache.service.ts и кэшируются на статик-сервере.

URL_BUILDERS.buildAvatarUrl() возвращает relative path — hook подхватывает.

buildAvatarUrl — широкое использование

buildAvatarUrl используется в 13 файлах across 7 доменов (users, referrals, seasons, streaks, bootstrap, admin, frontend). Основной паттерн — fallback для null user.avatarUrl: когда avatarUrl ещё не закэширован, buildAvatarUrl формирует предполагаемый relative path по telegramId. Этот fallback применяется в leaderboards, referral списках, bootstrap данных, профилях пользователей и raffle участниках.


5. SSE Exception

SSE endpoints используют reply.raw.write(), который обходит Fastify serialization pipeline (включая preSerialization hook).

SSE EndpointImage URLs?Нормализация
Feed (/api/feed/live)Да (item.imageUrl, case.imageUrl)Вручную через transform-event.utils.ts
Raffle (/api/raffle/:id/live)Нет (только buyerName, prizeTitle)Не требуется
// feed/utils/transform-event.utils.ts — единственное место с ручным normalizeImageUrl
import { normalizeImageUrl } from '@common/utils/image-url.utils';

imageUrl: normalizeImageUrl(item.imageUrl), // transformItem()
imageUrl: normalizeImageUrl(event.case.imageUrl), // transformEvent()

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

СитуацияЧто делать
Новый upload routeimageUrl = relativePath (например, `${IMAGE_PATHS.ITEMS}/${fileName}`) → сохранить в БД
Новый seed файлimageUrl = '/images/...' — relative path
Новый frontend компонент<img src={item.imageUrl} /> — без трансформации (hook уже подставил full URL)
Новый backend endpointВозвращать imageUrl из БД as-is — hook нормализует автоматически
Тест / fixtureИспользовать relative path: /images/items/item.webp
avatarUrl в ответе APIВозвращать as-is — hook нормализует автоматически
Новый SSE endpoint с image URLsИспользовать normalizeImageUrl() из @common/utils/image-url.utils вручную

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

ФайлНазначение
backend/src/common/hooks/image-url.hook.tsCentralized preSerialization hook
backend/src/common/utils/image-url.utils.tsnormalizeImageUrl() — используется SSE
backend/src/common/constants/url.constants.tsSSOT: IMAGE_PATHS + URL_BUILDERS
backend/src/server.tsРегистрация hook (строка ~223)
backend/src/domains/feed/utils/transform-event.utils.tsSSE exception: ручная нормализация
backend/src/domains/cases/routes/admin-items-upload.routes.tsUpload route — сохраняет relative path
backend/src/domains/cases/routes/admin-cases-upload.routes.tsUpload route — сохраняет relative path
backend/src/domains/admin/routes/admin-uploads.routes.tsUpload routes (banners, push) — сохраняет relative path
frontend/src/config/urls.tsFrontend URL builders (для non-DB assets)
backend/prisma/seed/seed-resources.tsSeed — сохраняет relative path
backend/prisma/seed/seed-buffs.tsSeed — сохраняет relative path