Static Content URL Strategy
Стратегия хранения и формирования URL статического контента (изображения предметов, кейсов, баннеров, аватаров).
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 Frontend | goloot.online | Инвентарь, кейсы, крафт, квесты |
| Admin Panel | admin.goloot.online | Управление предметами, кейсами |
| Backend API | api.goloot.online | Feed, уведомления, OpenGraph |
| Telegram Bot | — | Inline-превью |
Если хранить относительный путь /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_URLenv 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 route | imageUrl = 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.ts | SSOT: IMAGE_PATHS + URL_BUILDERS |
frontend/src/config/urls.ts | Frontend URL builders (для non-DB assets) |
backend/src/domains/cases/routes/admin-items-upload.routes.ts | Upload route — формирует полный URL |
backend/src/domains/quizzes/repositories/quiz.repository.ts | Исключение: normalizeImageUrl для quizzes |
backend/prisma/seed/seed-resources.ts | Seed — использует STATIC_URL |
Related
- Services Deployment — структура goloot_static volume
- Environment Variables — STATIC_URL, VITE_STATIC_URL
- Common Structure — url.constants.ts