Static Content URL Strategy
Стратегия хранения и формирования URL статического контента (изображения предметов, кейсов, баннеров, аватаров).
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 доменов — см. ниже) |
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 | Пропускается |
| Любая другая строка | Пропускается |
Обход рекурсивный — обрабатывает вложенные объекты, массивы, любую глубину.
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 Frontend | goloot.online | Инвентарь, кейсы, крафт, квесты |
| Admin Panel | admin.goloot.online | Управление предметами, кейсами |
| Backend API | api.goloot.online | Feed, уведомления, OpenGraph |
| Telegram Bot | — | Inline-превью |
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) - Единый механизм нормализации —
preSerializationhook покрывает все endpoints - Zero maintenance — новые endpoints автоматически покрываются
- Простота миграции — при смене домена статики достаточно обновить
STATIC_URLenv 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.webp | admin-items-upload.routes.ts |
| Cases | /images/cases/case.webp | admin-cases-upload.routes.ts |
| Banners | /images/banners/ad.webp | admin-uploads.routes.ts |
| Quizzes | /images/quizzes/quiz.webp | admin-quizzes-upload.routes.ts |
| Avatars | /images/avatars/123456.jpg?v=1234 | avatar-cache.service.ts |
| Buffs (seed) | /images/items/buffs/xp.webp | seed-buffs.ts |
| Resources (seed) | /images/items/resources/wood.webp | seed-resources.ts |
Avatars
User.avatarUrl хранится как /images/avatars/{telegramId}.jpg?v={mtime}. Аватары формируются асинхронно через avatar-cache.service.ts и кэшируются на статик-сервере.
URL_BUILDERS.buildAvatarUrl() возвращает relative path — hook подхватывает.
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 Endpoint | Image 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 route | imageUrl = 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.ts | Centralized preSerialization hook |
backend/src/common/utils/image-url.utils.ts | normalizeImageUrl() — используется SSE |
backend/src/common/constants/url.constants.ts | SSOT: IMAGE_PATHS + URL_BUILDERS |
backend/src/server.ts | Регистрация hook (строка ~223) |
backend/src/domains/feed/utils/transform-event.utils.ts | SSE exception: ручная нормализация |
backend/src/domains/cases/routes/admin-items-upload.routes.ts | Upload route — сохраняет relative path |
backend/src/domains/cases/routes/admin-cases-upload.routes.ts | Upload route — сохраняет relative path |
backend/src/domains/admin/routes/admin-uploads.routes.ts | Upload routes (banners, push) — сохраняет relative path |
frontend/src/config/urls.ts | Frontend URL builders (для non-DB assets) |
backend/prisma/seed/seed-resources.ts | Seed — сохраняет relative path |
backend/prisma/seed/seed-buffs.ts | Seed — сохраняет relative path |
Related
- Services Deployment — структура goloot_static volume
- Environment Variables — STATIC_URL, VITE_STATIC_URL
- Common Structure — url.constants.ts
- Profile — Avatar Caching — avatarUrl relative path паттерн