Files
iu 6e68242ec2 chore(openspec): архивировать album-live-sync и синхронизировать основные спеки
- realtime: личный поток несёт события жизненного цикла альбома и изменения
  счётчика фото; добавлены требования о жизненном цикле, получателях, форме
  payload'а, догоне и потере доступа
- albums: photos_count считает только фото вне корзины
2026-10-15 23:25:07 +03:00

18 KiB
Raw Permalink Blame History

Design

Context

Мотивация — в proposal.md. Состояние, важное для подхода:

  • Хаб internal/sse держит два независимых пространства подписок: albums[albumID] и users[userID]; публикация в личный канал уже реализована (PublishUser) и уже используется — единственным продюсером является notifications.Service.Create.
  • Пер-альбомные события публикуются из albums.Service.Update/Delete, members.Service.Accept/ChangeRole/Remove, photos.Service, social.Service.
  • Персональный поток GET /api/sse/me доступен клиентам, авторизация принимает токен в query-параметре, поэтому существующий web-обёртка SSEClient подходит без изменений.
  • Объект альбома в ответе списка собирается SQL-выборкой с джойном обложки и подзапросами счётчиков (photos_count, members_count); albums.Service.Create и Update возвращают объект без обложки, поэтому его нельзя переиспользовать как payload события «как есть».
  • Владелец альбома не входит в album_members: albums.owner_id — отдельный источник (владелец не меняет роль и не удаляется из состава).
  • album_members ссылается на albums с ON DELETE CASCADE: после удаления альбома состав участников уже недоступен.
  • Web-клиент подписан на SSE только на странице альбома; список альбомов загружается один раз за сессию и не обновляется по событиям. Android-парсер пер-альбомного потока игнорирует неизвестные типы событий.

Goals / Non-Goals

Goals:

  • Доставлять события жизненного цикла альбома (создание, метаданные, состав, удаление) тем пользователям, у которых меняется список альбомов, независимо от того, что они смотрят.
  • Обновлять список альбомов и счётчик photos_count при изменении состава фото (загрузка, перемещение, копирование, восстановление, корзина, окончательное удаление).
  • Отдавать в событии объект альбома той же формы, что REST-список, чтобы клиент применял его без запроса.
  • Дать web-клиенту единую подписку на персональный поток и догон состояния при реконнекте.

Non-Goals:

  • Персистентные уведомления, их список и UI непрочитанных — не затрагиваются.
  • Android-клиент не подключается к личному потоку в этом этапе; пер-альбомный контракт должен остаться прежним.
  • Серверный обрыв пер-альбомного SSE при исключении участника не входит в этап.
  • Новые HTTP-эндпоинты и изменения схемы БД не требуются.

Decisions

1. Канал доставки — существующий личный поток, веер по владельцу и участникам. Событие публикуется в личный канал каждого затронутого пользователя. Альтернативы: (а) публиковать только в канал альбома — не доходит до тех, кто его не смотрит, то есть не решает задачу; (б) новое пространство подписок «альбомы пользователя» — дублирует уже существующий личный канал без выигрыша; (в) WebSocket — ломает сложившийся механизм SSE и требует второго транспорта. Выбран веер по users[userID]: переиспользует готовую инфраструктуру и не меняет транспорт.

2. Payload — полный объект альбома, а не сигнал-инвалидация. Клиент применяет upsert/remove без запроса; порядок списка не меняется. Альтернатива — тонкий сигнал «список изменился» и рефетч: проще на сервере, но добавляет запрос на каждое изменение и оставляет окно, когда UI показывает старое состояние; при массовых операциях (приглашение нескольких участников) даёт несколько лишних запросов. Отвергнуто в пользу полного payload'а при том же объёме клиентской логики.

3. Источник payload'а — тот же код, что формирует элемент списка. Перед публикацией альбом перечитывается через Store.GetByID и обогащается attachCover, то есть формой он идентичен элементу GET /api/albums. Причина: Create/Update отдают объект без URL обложки, а Create — ещё и без счётчиков; сборка объекта вручную в точке публикации неизбежно разошлась бы с REST-формой.

4. Резолвер аудитории — лёгкий список идентификаторов пользователей. Нужен метод, возвращающий id владельца и id участников без джойна с users. Альтернатива — переиспользовать members.Repository.List, но он тянет PublicUser (join с users) и возвращает данные, которые никуда не идут; для веера это лишняя работа на каждое событие. Метод добавляется в репозиторий и закрывается интерфейсом в сервисах, как уже сделано для OwnerID.

5. Аудиторию удаления вычисляем до удаления альбома. Remove/Delete должны собрать список получателей заранее: album_members удаляется каскадом вместе с альбомом, а GetByID после удаления недоступен. Для album.deleted payload'у достаточно только id, поэтому перечитывать объект не нужно.

6. Для исключённого участника переиспользуем album.deleted. Отдельный тип («доступ отозван») не вводим: клиентская реакция одинакова — убрать альбом из списка и закрыть открытую страницу, а таксономия остаётся минимальной. Альтернатива — album.access.revoked/member.left в личном потоке: семантически точнее, но добавляет второй путь обработки и тип события ради отсутствующего сейчас UI-сообщения. Компромисс: если позже появятся текстовые уведомления («вас исключили»), тип придётся разделить — это зафиксировано в рисках.

7. Точки публикации.

Событие Триггер Получатели
album.created создание альбома владелец
album.created принятие приглашения приглашённый
album.updated принятие приглашения владелец и участники (кроме приглашённого)
album.updated смена названия/описания/обложки владелец и участники
album.updated смена роли участника владелец и участники
album.updated исключение участника владелец и оставшиеся участники
album.updated загрузка/копирование/перемещение/восстановление/корзина/удаление фото владелец и участники (личный поток)
photo.created загрузка фото в альбом подписчики канала альбома
album.deleted удаление альбома владелец и участники (аудитория до удаления)
album.deleted исключение участника исключённый

11. Изменения состава фото публикуются только в личный поток. album.updated при изменении состава фото уходит получателям в личные каналы, но не в канал альбома: открытая страница узнаёт об изменениях из пер-альбомных photo.created / photo.deleted, а пер-альбомный album.updated заставлял бы её перечитывать альбом через REST на каждое фото (массовая загрузка = поток лишних запросов). Личные каналы для карточек обходятся без REST. Дополнительно Upload публикует photo.created в канал альбома — это реализация уже существующего требования способности realtime («События альбома»), а не новый контракт.

12. photos_count считает только фото вне корзины. Запросы альбома добавляют deleted_at IS NULL к подзапросу счётчика в sqlite- и postgres-сторах. Причина: сейчас счётчик включал фото в корзине и расходился с сеткой, из-за чего «удаление» не меняло бы карточку. Так счётчик совпадает с видимым составом, а перевод в корзину, восстановление и окончательное удаление меняют его предсказуемо. Уже помещённые в корзину фото перестают учитываться без миграции данных.

8. Пер-альбомный album.updated сохраняется. Метаданные продолжают публиковаться и в канал альбома: его ждёт Android-парсер (обрабатывает album.updated/album.deleted как «перечитать альбом»), а открытая страница альбома делает идемпотентную перезагрузку. Снимать пер-альбомную публикацию означало бы ломать совместимость мобильного клиента ради экономии одного запроса.

9. Web: одна подписка на уровне layout'а приложения. Подписка на /api/sse/me живёт вместе с layout'ом (app), а не с отдельными страницами, чтобы соединение не пересоздавалось при навигации; обработчики вызывают методы стора albums (upsert/remove), onReconnect вызывает albums.load(). Альтернатива — подписка на каждой странице: дублирование соединений и логики.

10. Страница альбома при потере доступа. При событии удаления/потери доступа страница перестаёт отображать альбом и уходит на главную; отдельного сообщения пользователю в этом этапе не делаем (это уже область центра уведомлений). Обработка нужна с обоих потоков: страница подписана и на канал альбома, и косвенно на личный.

Risks / Trade-offs

  • Дублирование album.updated (пер-альбомный и личный каналы) для открытой страницы → обработчики должны быть идемпотентны: личный делает upsert, страница — перезагрузку; лишний запрос на изменение метаданных принимаем осознанно.
  • Веер O(участники) на каждое изменение → при больших альбомах это стоимость пропорциональна числу подписанных пользователей, а не числу участников (публикация уходит в хаб, у которого нет подписчика — no-op). Массовые операции с фото (batch-удаление, перемещение) публикуют album.updated на каждое фото: для получателя это дешёвый upsert в сторе, на сервере — перечитывание альбома и веер по личным каналам. Это принято осознанно; при необходимости батчи можно схлопнуть в одно событие на альбом.
  • Backpressure хаба отключает медленного клиента → вместе с личным потоком клиент теряет и обновления списка; это уже существующее поведение SSE, и оно компенсируется догоном через albums.load() после переподключения.
  • album.deleted для исключённого семантически перегружен → если появятся текстовые уведомления, потребуется отдельный тип события; изменение аддитивное.
  • Исключённый участник продолжает получать пер-альбомный SSE до разрыва соединения (доступ проверяется только при подключении) → в этом этапе не закрывается, остаётся отдельной задачей; событие из личного потока закрывает список и открытую страницу, но не отменяет уже установленное соединение.
  • URL обложки в payload'е может истечь (presigned для S3) → URL формируется тем же кодом и с тем же TTL, что в REST-ответе; при переподключении клиент получает свежий список.
  • События SSE не имеют идентификаторов → повторная доставка и пропуски обрабатываются только догоном через REST; история событий не хранится.
  • photo.created при загрузке приходит без URL вариантов (thumb/preview ещё не готовы) → клиент вставляет фото-заглушку, а пришедший позже photo.updated заполняет URL на месте; порядок сетки не меняется.
  • photos_count теперь без корзины → у альбомов с фото в корзине отображаемое число уменьшится сразу после выката; данные не мигрируются.

Migration Plan

Изменение аддитивное: новых эндпоинтов и миграций нет, старые клиенты неизвестные события игнорируют, пер-альбомный контракт событий сохраняется.

  1. Задеплоить сервер целиком (статика вшита в бинарник): публикации и веер начинают работать сразу, web-клиент подписку получает в том же артефакте.
  2. Порядок отката: вернуть предыдущий бинарник — сервер снова публикует только пер-альбомные события, клиент в том же артефакте просто не получает событий в личном потоке и возвращается к загрузке списка при открытии страницы. Состояние БД не меняется (счётчик снова начнёт включать корзину — это поведение старого бинарника).
  3. Проверка после выката: список альбомов на втором устройстве/вкладке обновляется без перезагрузки при переименовании, смене обложки, добавлении и исключении участника, загрузке/перемещении/восстановлении/удалении фото и удалении альбома; исключённый видит исчезновение альбома из списка, а открытая страница добавляет загруженное фото.