feat(realtime): доставлять клиентам события жизненного цикла альбома и изменения состава фото

Публиковать album.created/updated/deleted и изменения состава фото в личный поток
SSE, а также photo.created при загрузке, чтобы списки альбомов, счётчики и открытые
страницы альбома обновлялись без перезагрузки.

- members.Repository получил Audience (владелец + участники) для дешёвого веера
- albums.Service рассылает события в личные потоки получателей, перечитывая альбом
  через GetByID и attachCover, чтобы payload совпадал с GET /api/albums
- members.Service публикует при принятии инвайта, смене роли и исключении
- photos.Service публикует photo.created при загрузке и album.updated при
  добавлении/копировании/перемещении/восстановлении/корзине/окончательном удалении
- photos_count теперь считает фото вне корзины
- web-клиент подписывается на /api/sse/me один раз на уровне layout'а (app) и
  применяет события к стору альбомов; страница альбома уходит при потере доступа
This commit is contained in:
iu
2026-10-15 23:25:07 +03:00
parent cbff7a340c
commit e82f45647c
31 changed files with 2157 additions and 28 deletions
+32 -3
View File
@@ -443,8 +443,10 @@ colors: {
- `DELETE /api/comments/:id`
### Real-time (SSE)
- `GET /api/sse/albums/:id` — события альбома
- `GET /api/sse/me` — персональный поток (приглашения, уведомления)
- `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 — детали реализации
@@ -455,6 +457,30 @@ 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` считает только фото вне
корзины и совпадает с числом фото в сетке альбома.
### Backend: hub на каналах
- В памяти `map[albumID]map[clientID]chan Event`
- Регистрация клиента при подключении, удаление при отключении
@@ -481,7 +507,10 @@ X-Accel-Buffering: no
### Догон пропущенного
`EventSource` не отправляет `Last-Event-ID` надёжно. Поэтому при переподключении
клиент догоняет пропущенное через REST: `GET /api/albums/:id/photos?since=<last_event_time>`.
клиент догоняет пропущенное через REST: `GET /api/albums/:id/photos?since=<last_event_time>`
для открытого альбома и `GET /api/albums` для списка альбомов (персональный поток).
Догон списка восстанавливает изменения, пропущенные за время разрыва, в том числе
удаления: удалённый альбом в ответе уже не приходит.
### Ограничения SSE
- Односторонний поток (server → client), действия клиента — обычные POST/PATCH/DELETE