Files
ds-galo/project.md
iu 44cb7c7d68 fix(realtime): доставлять обложку альбома после готовности вариантов
Карточка альбома не получала ссылку на обложку: она появляется в объекте
альбома только после генерации thumb/medium, а событие об этом шаге в
персональные потоки не публиковалось — счётчик обновлялся, обложка нет.

- albums.Service.NotifyCoverReady публикует album.updated получателям, когда
  варианты готовы у фото, являющегося текущей обложкой (иначе no-op, чтобы
  массовая загрузка не рассылала событие на каждый файл)
- photos: порт AlbumAccess расширен, worker вызывает публикацию после
  photo.updated
- project.md §11/§12: триггер добавлен в контракт персонального потока
2026-10-16 12:47:50 +03:00

37 KiB
Raw Permalink Blame History

Проект: Многопользовательский фотоальбом (galo)

1. Цель

Веб-приложение, где несколько пользователей создают общие фотоальбомы, загружают, просматривают, комментируют и организуют фотографии в реальном времени с разграничением прав доступа.

Целевая аудитория: семьи и друзья (отпуск, свадьбы, праздники), рабочие команды (мероприятия, стройка), фотографы и их клиенты.

Проблема, которую решаем: фото разбросаны по мессенджерам и галереям участников, теряется авторство и контекст, нет единого пространства с понятными правами.

2. Инфраструктура (уже существует)

  • S3: s3.localnet — Garage (S3-совместимое хранилище, self-hosted)
  • PostgreSQL: pg.localnet:5432 — БД, пользователь, пароль в env
  • Локальные Postgres и MinIO не поднимаем — только подключаемся к существующим

Ключевые особенности Garage

  • Path-style обязателен — UsePathStyle: true, виртуальный хостинг ({bucket}.{host}) не используется
  • Регион: garage (не стандартный us-east-1)
  • Access Key ID начинается с GK...
  • Presigned URL поддерживаются и являются предпочтительным способом отдачи файлов

3. Стек

Backend: Go 1.22+

  • HTTP: Gin (или Fiber)
  • БД: PostgreSQL через pgx (подключение к pg.localnet)
  • Хранилище: S3 через AWS SDK v2 (подключение к Garage на s3.localnet, path-style)
  • Real-time: Server-Sent Events (SSE) — нативный net/http + http.Flusher
  • Аутентификация: JWT (access 15 мин + refresh 30 дней)
  • Миграции: golang-migrate
  • Обработка изображений: imaging или bimg (превью + EXIF)
  • Логирование: slog (структурированные логи)

Frontend: SvelteKit 2 + Svelte 5

  • adapter-static — сборка в статику, раздаётся Go-бинарником через embed.FS
  • TypeScript
  • TailwindCSS — utility-first, семантические токены через CSS-переменные
  • Real-time: нативный EventSource с обёрткой для reconnect и backoff
  • Только UI — вся логика на Go, обращение через fetch к /api/*

Обоснование выбора

  • Svelte оправдан: маленькие бандлы (важно для галереи фото), простота кода, зрелость (Thoughtworks перевёл в «Adopt»), идеально как UI-слой над Go API
  • Tailwind оправдан: скорость разработки UI, автоматическая консистентность, 5–20 КБ gzip в продакшене, отлично гасится компонентным подходом Svelte
  • SSE вместо WebSocket: односторонний поток server→client полностью закрывает задачу (новые фото, комментарии, лайки); работает поверх HTTP/1.1 и HTTP/2, проходит через прокси, встроен в net/http без внешних либ

4. Запуск и деплой (без Docker)

Принципы

  • Никаких контейнеров. Backend — нативный Go-бинарник. Frontend — статика, вшитая в бинарник через embed.FS. Никакого Docker, docker-compose, Kubernetes.
  • Процесс-менеджер: systemd (unit для backend).
  • Сборка: Go-тулчейн на сборочной машине, Node.js только на этапе сборки фронтенда (в CI или локально), в продакшене Node.js не нужен.
  • Статика фронтенда копируется в backend/static/ и попадает в бинарник через //go:embed static.

Сборка backend

cd backend
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ../bin/galo ./cmd/server

Сборка frontend

cd frontend
npm ci
npm run build          # adapter-static → frontend/build/
rm -rf ../backend/static/*
cp -r build/* ../backend/static/

Порядок сборки

  1. Собрать frontend (npm run build)
  2. Скопировать build/* в backend/static/
  3. Собрать backend (go build) — статика вшивается в бинарник
  4. Положить бинарник на сервер и запустить через systemd

systemd unit /etc/systemd/system/galo.service

[Unit]
Description=Galo backend
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=album
Group=album
WorkingDirectory=/opt/galo
EnvironmentFile=/opt/galo/.env
ExecStart=/opt/galo/galo
Restart=on-failure
RestartSec=5s

# Безопасность
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/galo

[Install]
WantedBy=multi-user.target

Активация:

sudo systemctl daemon-reload
sudo systemctl enable --now galo
sudo journalctl -u galo -f

Прокси (опционально)

Если перед Go стоит nginx — критично выставить для SSE:

location /api/sse/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;              # иначе SSE буферизуется
    proxy_cache off;
    proxy_read_timeout 1h;
    chunked_transfer_encoding on;
}

5. Переменные окружения (.env)

# PostgreSQL
PG_HOST=pg.localnet
PG_PORT=5432
PG_USER=album
PG_PASSWORD=change_me
PG_DATABASE=galo
PG_SSLMODE=disable

# Metadata backend
DB_BACKEND=sqlite            # sqlite (default, embedded single instance) | postgres
SQLITE_PATH=./galo.db        # path when DB_BACKEND=sqlite

# S3 (Garage) - внутреннее хранилище, клиенту не раскрывается
S3_ENDPOINT=http://s3.localnet:3900
S3_REGION=garage
S3_ACCESS_KEY=GKchange_me
S3_SECRET_KEY=change_me
S3_BUCKET=albums
S3_USE_PATH_STYLE=true

# Storage backend
STORAGE_BACKEND=fs          # fs (default, local filesystem) | s3
FS_ROOT=./data                  # root directory for object blobs (fs)
URL_SECRET=change_me            # signs object URLs; falls back to the metadata-layer secret
OBJECT_URL_TTL=1h               # lifetime of signed object URLs

# Auth. Token signing secret is generated on first start and stored in the
# metadata layer (app_settings) — not configured via env.
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=720h

# Server
HTTP_ADDR=:8080
APP_BASE_URL=http://localhost:8080

# SSE
SSE_HEARTBEAT_INTERVAL=25s
SSE_CLIENT_BUFFER=64

Параметры клиента S3 для Garage

cfg, err := config.LoadDefaultConfig(ctx,
    config.WithRegion("garage"),
    config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
        os.Getenv("S3_ACCESS_KEY"),
        os.Getenv("S3_SECRET_KEY"),
        "",
    )),
)

client := s3.NewFromConfig(cfg, func(o *s3.Options) {
    o.BaseEndpoint = aws.String(os.Getenv("S3_ENDPOINT"))
    o.UsePathStyle = true  // обязательно для Garage
})

Важно:

  • UsePathStyle = true — без этого Garage вернёт ошибку DNS или SignatureDoesNotMatch
  • Регион должен совпадать со значением s3_region в конфиге Garage (обычно garage)
  • Access Key ID начинается с GK...

6. Роли и права

Действие Владелец Админ Редактор Участник Наблюдатель
Создать/удалить альбом ✅ ❌ ❌ ❌ ❌
Приглашать участников ✅ ✅ ⚠️ ❌ ❌
Загружать фото ✅ ✅ ✅ ✅ ❌
Удалять чужие фото ✅ ✅ ❌ ❌ ❌
Удалять свои фото ✅ ✅ ✅ ✅ ❌
Комментировать ✅ ✅ ✅ ✅ ❌
Менять роли ✅ ⚠️ ❌ ❌ ❌
Скачивать ✅ ✅ ✅ ✅ ⚠️

Права проверяются на уровне сервиса, не только UI.

7. Функционал MVP

  1. Аутентификация: регистрация, вход, refresh-токен
  2. Альбомы: создание, редактирование, удаление, список моих альбомов
  3. Участники: инвайт по ссылке с токеном, принятие, роли
  4. Фото: загрузка (по одному и массово), генерация 3 превью (thumb 200px / preview 1200px / original), извлечение EXIF
  5. Просмотр: сетка, полноэкранный лайтбокс, свайпы
  6. Социальное: лайки, комментарии
  7. Real-time через SSE: новые фото, комментарии, лайки, подключение участников
  8. Уведомления: web push о новых событиях
  9. Скачивание: фото по одному и альбомом в zip
  10. Три темы оформления: светлая, тёмная, нейтральная + системная

8. Темы оформления

Три темы + режим «системная», переключение через data-theme на <html>.

Токены в src/app.css

@import "tailwindcss";

/* Светлая */
:root, [data-theme="light"] {
  --bg: 0 0% 100%;  --bg-elevated: 0 0% 98%;  --bg-muted: 0 0% 95%;
  --border: 0 0% 88%;
  --text: 0 0% 9%;  --text-muted: 0 0% 45%;  --text-subtle: 0 0% 60%;
  --primary: 221 83% 53%;  --primary-fg: 0 0% 100%;
  --danger: 0 84% 60%;  --success: 142 71% 45%;
  --shadow: 0 0% 0% / 0.08;
}

/* Тёмная */
[data-theme="dark"] {
  --bg: 0 0% 7%;  --bg-elevated: 0 0% 11%;  --bg-muted: 0 0% 16%;
  --border: 0 0% 22%;
  --text: 0 0% 96%;  --text-muted: 0 0% 65%;  --text-subtle: 0 0% 45%;
  --primary: 217 91% 60%;  --primary-fg: 0 0% 100%;
  --danger: 0 84% 65%;  --success: 142 65% 50%;
  --shadow: 0 0% 0% / 0.4;
}

/* Нейтральная (тёплая серо-бежевая) */
[data-theme="neutral"] {
  --bg: 40 20% 96%;  --bg-elevated: 40 22% 92%;  --bg-muted: 40 18% 87%;
  --border: 40 14% 78%;
  --text: 30 10% 15%;  --text-muted: 30 8% 40%;  --text-subtle: 30 6% 55%;
  --primary: 25 90% 50%;  --primary-fg: 0 0% 100%;
  --danger: 0 72% 55%;  --success: 142 55% 42%;
  --shadow: 30 20% 20% / 0.12;
}

html, body {
  background: hsl(var(--bg));
  color: hsl(var(--text));
  transition: background-color 200ms ease, color 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
  html, body { transition: none; }
}

Маппинг в tailwind.config.ts

colors: {
  bg: {
    DEFAULT:  'hsl(var(--bg) / <alpha-value>)',
    elevated: 'hsl(var(--bg-elevated) / <alpha-value>)',
    muted:    'hsl(var(--bg-muted) / <alpha-value>)',
  },
  border: 'hsl(var(--border) / <alpha-value>)',
  text: {
    DEFAULT: 'hsl(var(--text) / <alpha-value>)',
    muted:   'hsl(var(--text-muted) / <alpha-value>)',
    subtle:  'hsl(var(--text-subtle) / <alpha-value>)',
  },
  primary: {
    DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
    fg:      'hsl(var(--primary-fg) / <alpha-value>)',
  },
  danger:  'hsl(var(--danger) / <alpha-value>)',
  success: 'hsl(var(--success) / <alpha-value>)',
}

В компонентах — только семантические классы: bg-bg-elevated text-text border-border, никаких dark: префиксов.

Защита от вспышки

Инлайн-скрипт в src/app.html ставит data-theme до отрисовки: читает localStorage.theme (или system), разрешает через matchMedia и устанавливает атрибут на <html>.

Store темы

src/lib/stores/theme.svelte.ts — Svelte 5 runes: $state для текущей темы, initTheme() в onMount в +layout.svelte, setTheme() пишет в localStorage и обновляет data-theme. Слушает matchMedia при режиме system.

Переключатель

ThemeSwitcher.svelte — 4 кнопки (☀️ Light / 🌙 Dark / 🌾 Neutral / 💻 System) с активным состоянием на семантических токенах.

Нюансы для фотоальбома

  • В тёмной теме под фото использовать bg-bg-muted и лёгкую рамку border-border — так фото не «режет» глаз
  • Подложка лайтбокса зависит от темы: тёмная — почти чёрная, светлая — белая полупрозрачная, нейтральная — тёплый тёмный
  • Контраст text-muted на bg-muted в нейтральной теме ≥ 4.5:1 (WCAG AA) — важно для чтения подписей и комментариев
  • Presigned URL от Garage не зависят от темы

9. Схема БД (в pg.localnet, схема public)

  • users (id, email, password_hash, name, avatar_url, created_at)
  • albums (id, owner_id, title, description, cover_photo_id, privacy, created_at)
  • album_members (album_id, user_id, role, invited_by, joined_at)
  • invites (id, album_id, token, created_by, expires_at, max_uses, uses)
  • photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif, deleted_at, created_at)
  • objects (id, storage_key, mime_type, size, width, height, checksum, state, created_at)
  • photo_objects (photo_id, object_id, role)
  • user_avatar_objects (user_id, object_id, role)
  • variant_jobs (photo_id, attempts, next_attempt_at, last_error)
  • comments (id, photo_id, user_id, text, created_at, updated_at)
  • likes (photo_id, user_id, created_at)
  • notifications (id, user_id, type, payload_json, read_at, created_at)

10. Слой хранения и слой метаданных

Слой хранения выбирается параметром STORAGE_BACKEND: s3 (S3-совместимое хранилище, по умолчанию) или fs (локальная файловая система, FS_ROOT). Оба бэкенда реализуют единый интерфейс storage.Backend и используют одни и те же непрозрачные ключи, поэтому переключение не меняет формат данных.

S3 (Garage) — непрозрачное объектное хранилище. Ключи не содержат сущностей метаданных (альбома, фото). Каждый физический файл адресуется своим object_id:

{bucket}/
└── objects/{object_id}/
    ├── original.{ext}
    ├── thumb.webp
    └── preview.webp

При STORAGE_BACKEND=fs те же ключи отображаются в файлы под FS_ROOT. Отдача идёт через сам сервис: GET /api/objects/<key> с HMAC-подписью (e, s), поддержкой Range и Content-Type. Один и тот же маршрут обслуживает авторизованных пользователей и анонимные share-ссылки.

Postgres — журнал истины. Таблица objects описывает каждый блоб (ключ, mime, размер, габариты, checksum, состояние pending|ready|deleting), таблица photo_objects связывает фото с объектами по ролям (original|thumb|preview), таблица user_avatar_objects — аватар пользователя с его нарезками (avatar|avatar_small), таблица variant_jobs — очередь генерации вариантов. Альбом, принадлежность и представление живут только в photos/albums.

Следствия:

  • Копирование между альбомами — только новые строки photos+photo_objects, ссылающиеся на те же объекты; операции с хранилищем не выполняются.
  • Перенос — только UPDATE photos.album_id; объекты не трогаются.
  • Удаление фото удаляет строки; фоновый воркер удаляет блоб лишь когда на объект не осталось ни одной ссылки (фото или аватар) — сборка мусора по ledger-состояниям.

Доступ: только через бэкенд — подписанный URL со сроком 1 час на /api/objects/<key>, независимо от бэкенда хранилища. Для S3 (Garage) байты стримятся из внутренней сети через бэкенд (ranged GetObject), presigned URL наружу не выдаются; для ФС — чтение с диска. Переключение бэкенда не переносит существующие блобы — перенос выполняется вручную.

Реконсиляция хранилища. Сборка мусора управляется журналом objects и не сканирует хранилище, поэтому осиротевшие блобы (нет строки в журнале) не удаляются автоматически. Ручная команда cmd/reconcile сверяет хранилище с журналом: по умолчанию только отчёт, --delete удаляет сироты старше --min-age (по умолчанию 1h) в пределах префикса objects/; пропавшие ключи только сообщаются.

11. API-эндпоинты

Auth

  • POST /api/auth/register
  • POST /api/auth/login
  • POST /api/auth/refresh
  • POST /api/auth/logout

Profile

  • GET|PATCH|DELETE /api/auth/me
  • POST /api/auth/me/password
  • POST /api/auth/me/identifiers
  • PATCH /api/auth/me/preferences
  • POST /api/auth/me/avatar — загрузка готового квадрата (multipart file)
  • DELETE /api/auth/me/avatar — сброс к заглушке
  • GET /api/avatars/:userID/:version?size=large — публичная отдача аватара (без авторизации)

Albums

  • GET /api/albums
  • POST /api/albums
  • GET /api/albums/:id
  • PATCH /api/albums/:id
  • DELETE /api/albums/:id

Members

  • GET /api/albums/:id/members
  • POST /api/albums/:id/invites
  • POST /api/invites/:token/accept
  • PATCH /api/albums/:id/members/:user_id
  • DELETE /api/albums/:id/members/:user_id

Photos

  • GET /api/albums/:id/photos?cursor=&limit=
  • POST /api/albums/:id/photos (multipart, поддержка batch)
  • GET /api/photos/:id
  • DELETE /api/photos/:id
  • GET /api/photos/:id/download

Social

  • POST /api/photos/:id/likes
  • DELETE /api/photos/:id/likes
  • GET /api/photos/:id/comments
  • POST /api/photos/:id/comments
  • DELETE /api/comments/:id

Real-time (SSE)

  • GET /api/sse/albums/:id — события открытого альбома (photo.*, like.*, comment.created, member.*, album.updated, album.deleted)
  • GET /api/sse/me — персональный поток: notification и события жизненного цикла альбома album.created, album.updated, album.deleted

12. SSE — детали реализации

Формат события

event: photo.created
data: {"id":"...","album_id":"...","thumb_url":"...","uploader":{...}}

Персональный поток (/api/sse/me)

Несёт события, адресованные конкретному пользователю:

Событие Когда payload
notification создано уведомление для пользователя объект уведомления
album.created альбом появился в списке пользователя (он его создал или принял инвайт) объект альбома
album.updated изменены название/описание/обложка, состав участников, состав фото или готовы варианты фото-обложки объект альбома
album.deleted альбом удалён владельцем или доступ отозван (исключение из участников) {"id":"..."}

Объект альбома в album.created и album.updated совпадает по полям с элементом ответа GET /api/albums: id, owner_id, title, description, cover_photo_id, cover_thumb_url, cover_medium_url, photos_count, members_count, created_at, updated_at. Событие рассылается веером по личным каналам владельца и каждого участника альбома; album.deleted при исключении участника получает только исключённый. Публикация аддитивна: клиенты, которые не знают новый тип события, его игнорируют, а пер-альбомный контракт (album.updated, album.deleted) сохранён.

Состав фото отражается в персональном потоке: загрузка, копирование, перемещение, восстановление, перевод в корзину и окончательное удаление публикуют album.updated получателям (в пер-альбомный канал album.updated по этим поводам не идёт — открытая страница узнаёт об изменениях из photo.*). photos_count считает только фото вне корзины и совпадает с числом фото в сетке альбома.

Ссылка на обложку (cover_thumb_url, cover_medium_url) появляется в объекте альбома только после генерации вариантов thumb/medium, поэтому готовность вариантов у фото, являющегося текущей обложкой, тоже публикует album.updated получателям — иначе карточки альбомов остаются с заглушкой вместо обложки. Готовность вариантов прочих фото объект альбома не меняет и события не порождает.

Backend: hub на каналах

  • В памяти map[albumID]map[clientID]chan Event
  • Регистрация клиента при подключении, удаление при отключении
  • Публикация — неблокирующая запись в канал каждого клиента
  • При переполнении буфера — отключение медленного клиента (backpressure)

Обязательные заголовки

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no

X-Accel-Buffering: no критичен, если перед Go стоит nginx.

Heartbeat

Каждые 25 сек — комментарий : heartbeat\n\n, чтобы прокси не рвали соединение.

Авторизация

EventSource не умеет кастомные заголовки, поэтому access-токен передаётся в query-параметре ?token=.... Компенсация: короткий TTL (15 мин) и переподключение с новым токеном. Альтернатива — refresh в HttpOnly-cookie (с CSRF-защитой).

Догон пропущенного

EventSource не отправляет Last-Event-ID надёжно. Поэтому при переподключении клиент догоняет пропущенное через REST: GET /api/albums/:id/photos?since=<last_event_time> для открытого альбома и GET /api/albums для списка альбомов (персональный поток). Догон списка восстанавливает изменения, пропущенные за время разрыва, в том числе удаления: удалённый альбом в ответе уже не приходит.

Ограничения SSE

  • Односторонний поток (server → client), действия клиента — обычные POST/PATCH/DELETE
  • Работает поверх HTTP/1.1 и HTTP/2, проходит через большинство прокси
  • Лимит 6 одновременных SSE-соединений на домен в HTTP/1.1 (в HTTP/2 нет)
  • Для чатов и игр не подходит — только для ленты событий

13. Структура проекта

galo/
├── backend/
│   ├── cmd/server/main.go
│   ├── internal/
│   │   ├── config/       # чтение env
│   │   ├── auth/         # JWT, middleware
│   │   ├── albums/       # handlers, service, repo
│   │   ├── photos/       # upload, resize, storage
│   │   ├── comments/
│   │   ├── likes/
│   │   ├── invites/
│   │   ├── sse/          # hub, broker, publisher
│   │   ├── storage/      # S3 клиент (Garage на s3.localnet)
│   │   └── db/           # подключение (pg.localnet), миграции
│   ├── migrations/
│   ├── static/           # сюда копируется build SvelteKit (go:embed)
│   └── go.mod
├── frontend/
│   ├── src/
│   │   ├── app.html                   # inline-скрипт для data-theme
│   │   ├── app.css                    # CSS-переменные трёх тем
│   │   ├── routes/
│   │   │   ├── +layout.svelte         # initTheme()
│   │   │   ├── +page.svelte           # список альбомов
│   │   │   ├── login/
│   │   │   ├── albums/[id]/
│   │   │   │   ├── +page.svelte       # сетка фото + SSE
│   │   │   │   ├── members/
│   │   │   │   └── settings/
│   │   │   └── invites/[token]/
│   │   ├── lib/
│   │   │   ├── api.ts                 # fetch-обёртка
│   │   │   ├── sse.ts                 # EventSource с reconnect
│   │   │   ├── stores/
│   │   │   │   ├── auth.svelte.ts
│   │   │   │   └── theme.svelte.ts
│   │   │   └── components/
│   │   │       ├── PhotoGrid.svelte
│   │   │       ├── Lightbox.svelte
│   │   │       ├── Uploader.svelte
│   │   │       ├── Comments.svelte
│   │   │       └── ThemeSwitcher.svelte
│   ├── static/
│   ├── svelte.config.js               # adapter-static
│   ├── vite.config.ts
│   └── package.json
├── deploy/
│   ├── galo.service           # systemd unit
│   └── nginx-sse.conf                 # пример прокси для SSE
├── tailwind.config.ts
├── .env.example
├── Makefile                           # build, run, migrate, deploy
└── README.md

14. Makefile (пример)

.PHONY: build frontend backend run migrate

frontend:
	cd frontend && npm ci && npm run build
	rm -rf backend/static/*
	cp -r frontend/build/* backend/static/

backend:
	cd backend && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
		-o ../bin/galo ./cmd/server

build: frontend backend

run: backend
	./bin/galo

migrate:
	cd backend && migrate -path migrations -database \
		"postgres://$(PG_USER):$(PG_PASSWORD)@$(PG_HOST):$(PG_PORT)/$(PG_DATABASE)?sslmode=$(PG_SSLMODE)" up

15. Требования к качеству

  • Все эндпоинты защищены JWT-мидлварой, кроме /auth/*
  • Права проверяются на уровне сервиса, не только UI
  • Загрузка фото: стриминг в Garage (s3.localnet), генерация 3 превью асинхронно
  • SSE: авторизация через токен, подписка только на доступные альбомы, heartbeat каждые 25 сек, backpressure для медленных клиентов
  • Пагинация через cursor (id или created_at), не offset
  • Ошибки API — единый формат {error: {code, message}}
  • Логирование через slog (структурированные логи)
  • Конфиг через env (12-factor)
  • Подключение к pg.localnet и Garage (s3.localnet) — с ретраями и health-check
  • Garage: обязательно UsePathStyle: true и регион garage
  • Без Docker: один Go-бинарник со вшитой статикой, запуск через systemd
  • SSE-эндпоинт не блокирует остальные запросы — отдельная горутина на клиента
  • Темы: никаких dark: префиксов в компонентах — только семантические токены

16. Порядок работы

  1. Структура проекта, .env.example, Makefile, systemd unit
  2. Подключение к pg.localnet, миграции
  3. Клиент S3 к Garage (s3.localnet), создание бакета если нет
  4. Auth: register/login/refresh
  5. CRUD альбомов + инвайты + роли
  6. Загрузка фото в Garage + превью + EXIF
  7. Лайки, комментарии
  8. SSE-хаб и публикация событий
  9. SvelteKit UI: все экраны + SSE-подписка + три темы
  10. Сборка: frontend → static → go:embed → бинарник
  11. README с инструкцией запуска (build, systemd, nginx)

17. Что НЕ делать в MVP

  • Распознавание лиц, ML-теги
  • Мобильные приложения, кроме Android-клиента (см. android/); iOS не планируется
  • Платные тарифы и биллинг
  • Печать фотокниг, экспорт в сторонние сервисы
  • Публичный API для внешних интеграций
  • Поднимать локальные Postgres и MinIO — используем pg.localnet и Garage
  • WebSocket — real-time только через SSE
  • Виртуальный хостинг для S3 ({bucket}.s3.localnet) — только path-style
  • Docker, docker-compose, Kubernetes — только нативный Go-бинарник + systemd

18. Пошаговые промпты для итеративной генерации

Шаг 1 — Скелет и подключения

Создай структуру проекта galo: backend (Go + Gin) и frontend (SvelteKit 2 + Svelte 5 + adapter-static + Tailwind). Без Docker — нативная сборка Go-бинарника, статика вшивается через go:embed. Используй существующие pg.localnet и Garage на s3.localnet. Добавь .env.example со всеми переменными (PG_, S3_, JWT_, HTTP_, SSE_*), config-пакет на Go, подключение к pg.localnet через pgx с ретраями, клиент S3 к Garage (AWS SDK v2, region=garage, UsePathStyle=true), health-check /api/health (проверяет БД и S3). Добавь Makefile: build (frontend → static → backend), run, migrate. Добавь systemd unit deploy/galo.service.

Шаг 2 — Миграции

Добавь golang-migrate и миграции для таблиц: users, albums, album_members, invites, photos, comments, likes, notifications. Применяются к pg.localnet. Реализуй репозитории с базовыми CRUD на pgx.

Шаг 3 — Аутентификация

Реализуй регистрацию и вход по email/паролю с bcrypt, выдачу JWT access (15 мин) и refresh (30 дней). Middleware для проверки токена. Эндпоинты: POST /api/auth/register, /login, /refresh, /logout. На фронтенде — страницы login и register, store для токена, автообновление при 401.

Шаг 4 — Альбомы и участники

Реализуй CRUD альбомов и систему инвайтов по токену. Роли: owner, admin, editor, member, viewer. Проверка прав на уровне сервиса. Эндпоинты согласно ТЗ. На фронтенде — список альбомов, страница альбома, страница участников, принятие инвайта.

Шаг 5 — Загрузка и просмотр фото

Реализуй загрузку фото в Garage с генерацией трёх превью (thumb 200px, preview 1200px, original) и извлечением EXIF. Структура ключей: albums/{album_id}/{original|preview|thumb}/{photo_id}.{ext|webp}. Отдавай фото через бэкенд: подписанный URL /api/objects/<key> (срок 1 час), S3-байты стримятся из внутренней сети через бэкенд. Эндпоинты загрузки, списка с cursor-пагинацией, удаления. На фронтенде — Uploader с drag&drop и прогрессом, PhotoGrid с ленивой загрузкой, Lightbox со свайпами.

Шаг 6 — SSE real-time

Реализуй SSE-хаб на Go: реестр клиентов по album_id, неблокирующая публикация, heartbeat каждые 25 сек, backpressure. Эндпоинт GET /api/sse/albums/:id с авторизацией через ?token=. Заголовки: text/event-stream, no-cache, X-Accel-Buffering: no. События: photo.created, photo.deleted, comment.created, like.added, like.removed, member.joined, member.left, album.updated. Публикуй события из сервисов. На фронтенде — обёртка sse.ts над EventSource с экспоненциальным backoff и догоном пропущенного через REST. Обновляй сетку и комментарии в live-режиме.

Шаг 7 — Темы оформления

Реализуй три темы (light, dark, neutral) + system через data-theme на <html>. CSS-переменные в app.css, маппинг в tailwind.config.ts на семантические токены (bg, bg-elevated, bg-muted, border, text, text-muted, text-subtle, primary, danger, success). Инлайн-скрипт в app.html ставит data-theme до отрисовки (защита от вспышки). Store theme.svelte.ts на Svelte 5 runes: initTheme() в onMount в +layout.svelte, setTheme() пишет в localStorage, слушает matchMedia для system. ThemeSwitcher.svelte — 4 кнопки с активным состоянием. В компонентах — только семантические классы, никаких dark: префиксов.

Шаг 8 — Сборка и деплой без Docker

Настрой Makefile: frontend собирается в статику, копируется в backend/static, backend собирается с go:embed. Финальный артефакт — один бинарник. Добавь systemd unit в deploy/galo.service с EnvironmentFile=.env и базовым харденингом (NoNewPrivileges, ProtectSystem, PrivateTmp). Добавь пример nginx-конфига с proxy_buffering off для SSE. Напиши README: как собрать, как положить бинарник, как запустить через systemd.