chore(openspec): архивировать android-live-albums и fix-album-cover-readiness

Дельта android-live-albums: android-client (+3 требования: живое обновление
списка, догон при переподключении, потеря доступа) и realtime (порядок списка
теперь правило отображения клиента).

Дельта fix-album-cover-readiness: realtime (+1 требование: album.updated при
готовности вариантов фото-обложки).
This commit is contained in:
iu
2026-10-16 12:48:32 +03:00
parent d0d6362a01
commit 14db79632a
13 changed files with 521 additions and 1 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-16
@@ -0,0 +1,123 @@
# Design
## Context
См. `proposal.md` — мотивация. Состояние и ограничения, определяющие подход:
- Android подписан на SSE **только** пер-альбомно: `AlbumSseClient` вызывается из
`AlbumViewModel` и живёт ровно столько, сколько живёт экран альбома. Парсер
`AlbumEventParser` сворачивает `album.updated` и `album.deleted` в один `AlbumChanged`,
а неизвестные типы возвращает как `null` (тихо игнорирует).
- Список альбомов — offline-first: UI наблюдает Room, а `AlbumRepository.refresh()`
заменяет кэш серверным снимком через `replaceAll`.
- `AlbumDto` уже несёт форму элемента `GET /api/albums` (`cover_thumb_url`,
`cover_medium_url`, `photos_count`, `members_count`), и `AlbumEntity` хранит те же поля,
поэтому payload события ложится в кэш без сборки объекта вручную.
- В Room между `photos` и `albums` **нет** `ForeignKey`/`CASCADE`: удаление строки альбома
не уносит его фото.
- `TokenStore` не наблюдаем: «залогинен» проверяется императивно, `logout()` чистит токен и
запускает навигацию. Наблюдаемого сигнала сессии, на который можно подписать стрим, нет.
- `AppContainer` живёт с процессом и запускает фоновые вещи в `start()` (прецедент —
`DeviceMediaObserver`), но у него нет крючка «сессия началась/закончилась».
- SSE-клиент Android авторизуется заголовком `Authorization` через интерцептор, поэтому
query-параметр `token`, нужный вебу, здесь не требуется.
- Бэкенд уже всё публикует: удаление альбома владельцем уходит и в канал альбома, и в
личные потоки; исключение участника доходит до исключённого **только** личным потоком
(в канал альбома уходит `member.left`, который клиент не использует).
## Goals / Non-Goals
**Goals:**
- Обновлять список альбомов из персонального потока без запроса списка на каждое событие.
- Закрыть потерю доступа одним путём, покрывающим и удаление владельцем, и исключение.
- Не терять изменения, пропущенные за время разрыва соединения.
**Non-Goals:**
- Центр уведомлений и приглашения (в Android их нет; `notification` — игнорируем).
- Серверный обрыв пер-альбомного SSE при исключении участника — существующее ограничение.
- Изменение пер-альбомного контракта событий и REST.
- Работа подписки в фоне и обход Doze.
- Сортировка списка — отдельный change (`android-album-sort`).
## Decisions
**1. Дом подписки — ViewModel главного экрана, клиент — в `AppContainer`.**
`AlbumsViewModel` создаётся на стартовой записи навигации после входа, переживает уход на
экран альбома/профиля (запись остаётся в back stack) и уничтожается на выходе из аккаунта
(`popUpTo ... inclusive`). Это функциональный аналог вебовой подписки на уровне layout'а:
жизненный цикл сессии даёт навигация, а не процессный scope.
Альтернативы: (а) `AppContainer.scope` — как веб, но нет наблюдаемой сессии и подписка
живёт в фоне; (б) подписка на каждом экране — соединения дублируются, список всё равно
остаётся мёртвым. Сам клиент кладём в `AppContainer` рядом с `albumSseClient` — как
зависимость, чтобы ViewModel оставался тонким.
**2. События применяются в Room, а не в UI-состояние.**
Обработчик вызывает `AlbumRepository` (upsert/удаление), поэтому и список, и открытый
экран альбома получают изменение сами, без дополнительных запросов.
Альтернатива — править `UiState` списка напрямую: тогда открытый экран альбома не узнаёт
ничего, и логика расходится с offline-first моделью.
**3. Потеря доступа — через переход состояния кэша, один путь на два триггера.**
`album.deleted` (удаление владельцем или исключение) удаляет альбом из Room; экран альбома
реагирует на переход «альбом был ненулевым и стал `null`». Флаг «уже видел» отличает это
от честного холодного старта без кэша и без сети.
Альтернатива — явные события (`AlbumDeleted` из пер-альбомного парсера и `album.deleted`
из личного) с прямым `onBack()`: два пути, дублирование и потребность знать собственный
`user_id` для разбора `member.left` (сейчас экран знает только роль).
**4. Удаление альбома уносит и его фото.**
Так как FK с каскадом нет, удаление в репозитории должно чистить и `photos` этого альбома.
Иначе сетка удалённого альбома остаётся живой из кэша.
**5. Отдельный клиент и парсер для личного потока.**
Личный поток несёт `album.created`, `album.updated`, `album.deleted` и `notification`.
`notification` игнорируется явно: у него собственный `type` внутри payload'а
(`member.joined`, `like.added`), который нельзя путать с типом SSE-события.
Альтернатива — обобщить `AlbumSseClient` параметром запроса и парсера: пока переиспользования
мало, обобщение усложняет существующий рабочий путь.
**6. Догон при реконнекте — существующий `AlbumRepository.refresh()`.**
`refresh()` делает `replaceAll` серверным снимком, поэтому заодно уносит альбомы,
удалённые за время разрыва. Отдельный механизм догона не нужен.
**7. Пер-альбомную обработку не трогаем.**
Открытый экран получает `album.updated`/`album.deleted` и из своего канала, и косвенно из
личного. Обработка идемпотентна (`upsert`/удаление), дублирующая работа невелика, а
трогать работающий путь ради экономии одного запроса не стоит.
**8. Unit-тесты гоняются только на debug-варианте.**
Compose-тестовая активность (`androidx.activity.ComponentActivity`) объявляется
`ui-test-manifest`, который подключён как debug-зависимость, поэтому release-манифест её
не содержит и Robolectric не может запустить `createComposeRule`. Источники тестов
вариант-агностичны, поэтому прогон один раз на debug покрывает то же самое, а тестовая
активность не попадает в production-манифест. Альтернатива — подключить
`ui-test-manifest` как `implementation` — отвергнута: она добавляет тестовую активность
в release-APK.
## Risks / Trade-offs
- **Отмена коллектора может не отменить `EventSource`** (`closed.await()` бросает до
`source.cancel()`) → утечка соединения и цикл реконнектов на удалённом альбоме → покрыть
тестом и, если подтвердится, завершать соединение в `finally`/`invokeOnClose`.
- **`sseClient` собран без `TokenAuthenticator`** → на 401 посреди длинной сессии клиент не
обновит токен, а переоткроется с прежним → покрыть сценарием истечения токена.
- **Порядок списка меняется от живых событий** (альбом упорядочены по времени изменения)
→ принято осознанно; средство управления порядком приходит в `android-album-sort`.
- **Исключённый участник продолжает получать пер-альбомный SSE** до разрыва соединения →
существующее ограничение, не закрывается здесь.
- **Фон и Doze** → подписка не переживает вытеснение процесса; реконнект и догон
восстанавливают состояние при возвращении.
## Migration Plan
Изменение аддитивное: схемы Room, эндпоинтов и контрактов не меняем. Выкатывается обычным
обновлением приложения; откат — предыдущая версия APK, при которой список снова обновляется
только по REST-снимку. Данные на сервере и в кэше не мигрируются.
## Open Questions
- Стоит ли позже перевести пер-альбомный `album.updated` на применение payload'а в Room
вместо REST-перезагрузки, чтобы убрать дублирующий запрос? На поведение и разбивку
задач не влияет.
@@ -0,0 +1,67 @@
# Proposal
## Why
Список альбомов в Android-клиенте обновляется только REST-снимком при открытии экрана,
поэтому изменения, сделанные с другого устройства или в веб-клиенте (создание,
переименование, смена обложки, изменение состава участников, удаление альбома, изменение
состава фото), не доходят до открытого приложения до ручного обновления. На вебе это уже
решено персональным потоком `GET /api/sse/me`, и сервер события публикует, но Android к
этому потоку не подключён.
Отдельно открытый экран альбома не обрабатывает потерю доступа: при удалении альбома
владельцем или исключении участника экран продолжает отображать устаревшие данные
(или пустую сетку с заголовком-заглушкой), а карточка остаётся в списке.
## What Changes
- Android подписывается на персональный поток `GET /api/sse/me` и применяет
`album.created`, `album.updated` и `album.deleted` к локальному кэшу (Room) без
дополнительного запроса списка.
- Подписка привязана к сессии: создаётся при входе на главный экран и завершается при
выходе из аккаунта, не пересоздаётся при навигации между экранами.
- Применение события — это мутация кэша: `upsert` объекта альбома или удаление альбома
и связанных с ним фото. Список и открытый экран альбома наблюдают Room и обновляются
сами, дополнительных запросов на каждое событие нет.
- `album.deleted` (удаление владельцем или исключение участника) убирает альбом из кэша;
открытый экран этого альбома перестаёт его показывать и возвращается к списку.
- При переподключении персонального потока клиент догоняет состояние REST-снимком
списка альбомов, поэтому изменения, пропущенные во время разрыва, не теряются.
- Порядок элементов списка — правило отображения клиента: событие, применённое к
локальному кэшу, может изменить позицию альбома (Android упорядочивает серверные
альбомы по времени изменения).
- События `notification` в персональном потоке игнорируются: в Android нет экрана
уведомлений и приглашений.
- **BREAKING**: нет. Изменение аддитивное: пер-альбомный контракт событий и REST остаются
прежними, неизвестные клиенту события он уже сейчас игнорирует.
## Capabilities
### New Capabilities
_Нет — поведение расширяет существующие способности `android-client` и `realtime`._
### Modified Capabilities
- `android-client`: добавляется живое обновление списка альбомов из персонального потока
(применение событий к кэшу без запроса списка, догон при переподключении) и обработка
потери доступа к альбому (удаление из кэша, уход открытого экрана).
- `realtime`: сценарий «Обновление альбома из списка» больше не требует сохранять порядок
списка от всех клиентов — порядок становится правилом отображения клиента.
## Impact
- **Код (Android):** новый клиент персонального потока (`data/network`), парсер событий
жизненного цикла альбома, применение событий в `data/repo` (Room), подписка в
`ui/albums/AlbumsViewModel`, обработка потери доступа в `ui/album/AlbumViewModel` и
`ui/album/AlbumScreen`, `di/AppContainer`.
- **Схема Room:** без изменений (новых колонок и миграций не требуется).
- **API:** новых эндпоинтов нет; существующий `GET /api/sse/me` начинает использоваться
мобильным клиентом. Для авторизации используется заголовок `Authorization` (у Android
есть интерцептор), поэтому query-параметр `token` не нужен.
- **Не затрагивается:** веб-клиент, бэкенд (события и веер уже реализованы), схема БД,
пер-альбомный поток, загрузка и синхронизация медиа.
- **Известное ограничение (фиксируется, не закрывается здесь):** пер-альбомный SSE
проверяет доступ только при подключении, поэтому исключённый участник продолжает
получать события альбома до разрыва соединения. Персональный поток закрывает список и
открытый экран, но не отменяет установленное соединение.
@@ -0,0 +1,55 @@
# Spec Delta
## ADDED Requirements
### Requirement: Живое обновление списка альбомов
Пока пользователь авторизован, приложение SHALL поддерживать персональный поток и
применять события жизненного цикла альбома к локальному кэшу: создавать или заменять
альбом по полученному объекту и удалять его по идентификатору, не выполняя
дополнительный запрос списка.
#### Scenario: Альбом создан или изменён в другом месте
- **WHEN** в персональный поток приходит событие создания или изменения доступного альбома
- **THEN** альбом появляется в списке или заменяется без запроса списка
#### Scenario: Неизвестное событие
- **WHEN** в персональный поток приходит событие, не относящееся к жизненному циклу альбома
- **THEN** приложение не меняет локальный список
#### Scenario: Навигация между экранами
- **WHEN** пользователь переходит с главного экрана на другой экран приложения
- **THEN** подписка на персональный поток не пересоздаётся
#### Scenario: Выход из аккаунта
- **WHEN** пользователь выходит из аккаунта
- **THEN** подписка на персональный поток закрывается
### Requirement: Догон списка при переподключении персонального потока
После восстановления персонального потока приложение SHALL приводить локальный список
альбомов в соответствие с серверным снимком, чтобы отразить изменения, пропущенные за
время разрыва соединения.
#### Scenario: Переподключение
- **WHEN** персональный поток восстановился после разрыва
- **THEN** приложение перезапрашивает список альбомов, удаляя из кэша альбомы, которых на сервере больше нет
### Requirement: Потеря доступа к альбому
Приложение SHALL переставать отображать альбом, удалённый владельцем или ставший
недоступным после исключения участника: альбом SHALL исчезать из локального кэша и
списка, а экран открытого альбома SHALL возвращаться к списку.
#### Scenario: Альбом удалён во время просмотра
- **WHEN** владелец удаляет альбом, пока участник просматривает экран этого альбома
- **THEN** экран перестаёт показывать альбом и возвращается к списку
#### Scenario: Участник исключён
- **WHEN** участника исключают из альбома
- **THEN** альбом исчезает из его списка, а открытый экран возвращается к списку
#### Scenario: Экран альбома не открыт
- **WHEN** альбом удалён, а его экран не открыт
- **THEN** карточка альбома исчезает из списка
#### Scenario: Отсутствие альбома в кэше без сети
- **WHEN** приложение открывает экран альбома, которого нет в кэше, и сеть недоступна
- **THEN** экран не считается потерявшим доступ и не закрывается сам
@@ -0,0 +1,20 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Применение события без повторного запроса
Клиент SHALL обновлять локальный список альбомов из payload'а события: создавать или
заменять альбом по полученному объекту и удалять его по идентификатору, не выполняя
дополнительный запрос списка.
#### Scenario: Обновление альбома из списка
- **WHEN** приходит событие обновления для альбома, который уже есть в списке клиента
- **THEN** клиент заменяет альбом по полученному объекту без дополнительного запроса списка
#### Scenario: Удаление альбома из списка
- **WHEN** приходит событие удаления для альбома, который есть в списке клиента
- **THEN** клиент убирает альбом из списка
#### Scenario: Порядок списка
- **WHEN** клиент применяет событие к списку альбомов
- **THEN** порядок элементов списка определяется правилами отображения этого клиента
@@ -0,0 +1,38 @@
# Tasks
## 1. Клиент персонального потока
- [x] 1.1 Добавить в `GaloApi` запрос персонального потока (`api/sse/me`, `Accept: text/event-stream`); проверить request-тестом, что URL собран от базового адреса и заголовок выставлен
- [x] 1.2 Добавить парсер событий жизненного цикла альбома (`album.created`, `album.updated`, `album.deleted` -> типы с объектом/идентификатором; `notification` и неизвестные -> игнорировать); проверить unit-тестами на каждый тип, на неизвестный тип и на битый JSON
- [x] 1.3 Добавить `PersonalSseClient` по образцу `AlbumSseClient` (backoff, событие `Reconnected` после разрыва); проверить тестом: события доходят в порядке получения, после разрыва приходит `Reconnected`
- [x] 1.4 Проверить завершение соединения при отмене коллектора (отменяется ли `EventSource`, когда поток перестают собирать), при необходимости закрывать его в `finally`/`invokeOnClose`; проверить тестом, что после отмены поток не переоткрывается
## 2. Применение событий к кэшу
- [x] 2.1 Вывести удаление в `AlbumLocalSource` (`deleteByIds`) и `PhotoLocalSource` (очистка фото альбома); проверить тестами локального источника, что записи удаляются
- [x] 2.2 Добавить в `AlbumRepository` применение событий: `album.created`/`album.updated` заменяют альбом, `album.deleted` удаляет альбом вместе с его фото; проверить тестами на фейках: обновлённый объект виден из `observeAlbums`, удалённый исчезает вместе со своими фото
- [x] 2.3 Связать клиент персонального потока с репозиторием в `AppContainer` и обработчиком событий; проверить тестом, что событие из потока меняет то, что отдаёт `observeAlbums`
## 3. Подписка на время сессии
- [x] 3.1 Подписаться на персональный поток в `AlbumsViewModel` и завершать подписку при уничтожении ViewModel; проверить тестом, что подписка создаётся один раз и не пересоздаётся при повторном обращении к состоянию
- [x] 3.2 На `Reconnected` вызывать `albumRepository.refresh()`; проверить тестом, что альбом, удалённый на сервере за время разрыва, исчезает из кэша после догона
- [x] 3.3 Обновить `android/README.md`, раздел «Live updates»: персональный поток, обновление списка и потеря доступа; проверить, что описанный эндпоинт совпадает с реализованным запросом
## 4. Потеря доступа к альбому
- [x] 4.1 Отслеживать в `AlbumViewModel` факт «альбом уже был показан» и отдавать сигнал потери доступа при переходе «ненулевой -> отсутствует»; проверить unit-тестами оба случая: был и исчез — сигнал есть, не было изначально (нет кэша, нет сети) — сигнала нет
- [x] 4.2 По сигналу потери доступа экран альбома возвращается к списку; проверить Compose-тестом (Robolectric), что экран инициирует возврат, а список больше не содержит альбом
- [x] 4.3 Проверить сценарий исключения участника: `album.deleted` приходит только личным потоком, альбом исчезает из списка и открытый экран уходит назад
## 5. Интеграционная проверка
- [x] 5.1 Прогнать сквозной сценарий на двух аккаунтах против локального сервера: создание, переименование, смена обложки, удаление альбома и исключение участника — список и открытый экран обновляются без ручного обновления; приложить результат (шаги и наблюдение)
- [x] 5.2 Проверить поведение при истечении токена в долгоживущем потоке: соединение восстанавливается после обновления токена; если нет — зафиксировать ограничение в README
- [x] 5.3 Прогнать на финальной ревизии `cd android && ./gradlew test` и `cd android && ./gradlew :app:assembleDebug`; зафиксировать, что обе команды проходят
- [x] 5.4 Прогонять unit-тесты только на debug-варианте (тестовая активность Compose приходит из debug-зависимости `ui-test-manifest`), чтобы `cd android && ./gradlew test` проходил без тестовой активности в release-манифесте; проверить `./gradlew test`, `:app:assembleDebug` и `:app:assembleRelease`
## Workflow follow-up
- Заархивировать change после прохождения ревью проекта (`openspec archive android-live-albums`).
- Проверить, что после архивации требования вошли в `openspec/specs/android-client/spec.md` и `openspec/specs/realtime/spec.md`, а `openspec validate --strict android-live-albums` не выдаёт ошибок.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-16
@@ -0,0 +1,65 @@
# Design
## Context
См. `proposal.md` — мотивация. Что важно для подхода:
- Ссылки на обложку формируются из `photo_objects` (роли `thumb`/`medium`), которые
заполняются асинхронно, поэтому в объекте альбома (`Store.GetByID` джойнит обложку) они
появляются не в момент загрузки, а когда варианты готовы.
- Загрузка фото вызывает `SetCoverIfEmpty`, то есть первое фото становится обложкой ещё до
генерации вариантов, а `NotifyAlbumChanged` на этом шаге публикует `album.updated` уже с
`cover_photo_id`, но с пустыми ссылками.
- Готовность вариантов обрабатывает `Worker.notify` (`internal/photos/worker.go`): он
публикует только `photo.updated` в канал альбома и персональные потоки не трогает.
- `albums.Service` уже умеет перечитать альбом, обогатить обложку (`attachCover`) и веерно
опубликовать `album.updated` (`NotifyAlbumChanged` → `PublishAlbumTo`).
## Goals / Non-Goals
**Goals:**
- Доставить актуальную ссылку на обложку в персональные потоки, когда она появляется.
- Не увеличивать поток событий при массовой загрузке.
**Non-Goals:**
- Менять контракт `photo.updated` в канале альбома (открытый экран закрывает свой случай сам).
- Клиентские обходные пути (рефетч списка по таймеру и т.п.).
- Синхронная генерация вариантов при загрузке.
## Decisions
**1. Публикует `albums.Service`, а не воркер.**
Решение «объект альбома изменился» принимает владелец альбомов: добавляется
`NotifyCoverReady(ctx, albumID, photoID)`, который перечитывает альбом и публикует
`album.updated`, только если `cover_photo_id == photoID` (иначе no-op), переиспользуя
`NotifyAlbumChanged`. Воркер остаётся про фото и лишь сообщает о готовности.
Альтернатива — строить `album.updated` прямо в воркере: тогда `photos` пришлось бы знать
форму объекта альбома, обложку и аудиторию.
**2. Условие — совпадение с текущей обложкой.**
Готовность вариантов обычного фото объект альбома не меняет. Без условия пришлось бы на
каждое фото перечитывать альбом и веерно публиковать `album.updated` всем участникам — лишняя
нагрузка при массовой загрузке и лишние события клиентам.
Альтернатива — «публиковать всегда»: проще, но шумно; отвергнуто.
**3. Проверка по `cover_photo_id`, а не по «в альбоме ещё нет ссылки на обложку».**
Так закрываются оба сценария: и автопоставленная обложка первой загрузкой, и явная установка
обложки на фото, варианты которого ещё не готовы.
## Risks / Trade-offs
- **Двойное чтение альбома** (`GetByID` для проверки обложки и внутри `PublishAlbumTo`) →
дополнительное чтение точечное (только для готовности вариантов фото-обложки), принято; при
необходимости схлопывается в один публикующий вызов.
- **Гонка со сменой обложки** между публикацией `photo.created` и готовностью вариантов →
проверка по актуальному `cover_photo_id` отсекает лишнюю публикацию, а новая обложка получит
своё событие при готовности её вариантов.
- **Аудитория недоступна** (ошибка в `members.Audience`) → событие не публикуется; клиент
догоняет состояние при переподключении (`refresh`) — существующее поведение веера.
## Migration Plan
Изменение аддитивное: схема и API не меняются, нужен только перезапуск бэкенда (логика и
статика в одном бинарнике). Откат — предыдущий бинарник.
@@ -0,0 +1,41 @@
# Proposal
## Why
Обложка альбома на карточке в списке не обновляется вживую: после загрузки фото счётчик
`photos_count` приходит, а обложка остаётся заглушкой до ручного обновления. Ссылка на
обложку (`cover_thumb_url` / `cover_medium_url`) появляется в объекте альбома только после
генерации вариантов thumb/medium, но событие об этом в персональные потоки не публикуется.
Дефект серверной стороны способности `realtime`, поэтому затрагивает и веб-клиент, и Android.
## What Changes
- Когда для фото, являющегося обложкой альбома, готовы варианты, система публикует
`album.updated` с актуальным объектом альбома в персональные потоки владельца и
участников — именно тогда объект впервые получает ссылку на обложку.
- Готовность вариантов фото, которое обложкой не является, объект альбома не меняет и
события не порождает: иначе массовая загрузка даёт лишний веер `album.updated` на каждый
файл.
- Контракт существующих событий и REST не меняется, новых эндпоинтов и миграций нет.
- **BREAKING**: нет.
## Capabilities
### New Capabilities
_Нет — поведение расширяет существующую способность `realtime`._
### Modified Capabilities
- `realtime`: добавляется публикация `album.updated` в персональные потоки при готовности
вариантов фото-обложки, поскольку только тогда объект альбома получает ссылку на обложку.
## Impact
- **Код (backend):** `internal/albums/service.go` (новая публикация), порт `AlbumAccess` в
`internal/photos/service.go`, вызов из `internal/photos/worker.go` (готовность вариантов),
стаб порта в `internal/photos/helpers_test.go`.
- **Документация:** `project.md` (§11 «Real-time (SSE)», §12 «SSE — детали реализации»).
- **Не затрагивается:** схема БД, миграции, REST, пер-альбомный контракт (`photo.updated`
по-прежнему приходит в канал альбома), клиенты — веб и Android уже применяют
`album.updated` из персонального потока.
@@ -0,0 +1,17 @@
# Spec Delta
## ADDED Requirements
### Requirement: Обновление альбома при готовности вариантов обложки
Когда для фото, являющегося обложкой альбома, сгенерированы варианты, система SHALL
публиковать `album.updated` с актуальным объектом альбома в персональные потоки владельца и
участников: только тогда объект получает ссылку на обложку. Готовность вариантов прочих фото
событий не порождает.
#### Scenario: Варианты обложки готовы
- **WHEN** для фото, которое является обложкой альбома, сгенерированы thumb и medium
- **THEN** владелец и участники получают `album.updated` с непустой ссылкой на обложку
#### Scenario: Варианты обычного фото готовы
- **WHEN** варианты готовы для фото, которое не является обложкой альбома
- **THEN** персональные потоки не получают `album.updated`
@@ -0,0 +1,20 @@
# Tasks
## 1. Публикация при готовности вариантов обложки
- [x] 1.1 Добавить в `albums.Service` публикацию `album.updated` в персональные потоки, когда варианты готовы у фото, являющегося обложкой (проверка по актуальному `cover_photo_id`), переиспользуя существующий веер; проверить тестом: владелец и участник получают событие со ссылкой на обложку, а для фото, которое обложкой не является, публикации нет
- [x] 1.2 Расширить порт `AlbumAccess` в `photos` и вызвать публикацию из `Worker.notify` после `photo.updated`, обновив стаб порта в тестах; проверить тестом, что готовность вариантов фото вызывает публикацию
## 2. Документация контракта
- [x] 2.1 Обновить `project.md` (§11 «Real-time (SSE)» и §12 «SSE — детали реализации»): `album.updated` публикуется также при готовности вариантов фото-обложки; проверить, что перечисленные триггеры совпадают с реализованными публикациями
## 3. Проверка
- [x] 3.1 Прогнать `cd backend && go test ./...` и зафиксировать, что проходит
- [x] 3.2 Воспроизвести сценарий на временном инстансе: подписка на `/api/sse/me`, загрузка фото в альбом без обложки — после готовности вариантов приходит `album.updated` с непустой ссылкой на обложку; приложить вывод
## Workflow follow-up
- Заархивировать change после прохождения ревью проекта (`openspec archive fix-album-cover-readiness`).
- Проверить, что после архивации требование вошло в `openspec/specs/realtime/spec.md`, а `openspec validate --strict fix-album-cover-readiness` не выдаёт ошибок.
+52
View File
@@ -619,3 +619,55 @@ SHALL показывать значками расположение содер
#### Scenario: Не сопоставлено #### Scenario: Не сопоставлено
- **WHEN** сопоставления с серверным фото нет - **WHEN** сопоставления с серверным фото нет
- **THEN** лист показывает локальные атрибуты и локальные действия элемента - **THEN** лист показывает локальные атрибуты и локальные действия элемента
### Requirement: Живое обновление списка альбомов
Пока пользователь авторизован, приложение SHALL поддерживать персональный поток и
применять события жизненного цикла альбома к локальному кэшу: создавать или заменять
альбом по полученному объекту и удалять его по идентификатору, не выполняя
дополнительный запрос списка.
#### Scenario: Альбом создан или изменён в другом месте
- **WHEN** в персональный поток приходит событие создания или изменения доступного альбома
- **THEN** альбом появляется в списке или заменяется без запроса списка
#### Scenario: Неизвестное событие
- **WHEN** в персональный поток приходит событие, не относящееся к жизненному циклу альбома
- **THEN** приложение не меняет локальный список
#### Scenario: Навигация между экранами
- **WHEN** пользователь переходит с главного экрана на другой экран приложения
- **THEN** подписка на персональный поток не пересоздаётся
#### Scenario: Выход из аккаунта
- **WHEN** пользователь выходит из аккаунта
- **THEN** подписка на персональный поток закрывается
### Requirement: Догон списка при переподключении персонального потока
После восстановления персонального потока приложение SHALL приводить локальный список
альбомов в соответствие с серверным снимком, чтобы отразить изменения, пропущенные за
время разрыва соединения.
#### Scenario: Переподключение
- **WHEN** персональный поток восстановился после разрыва
- **THEN** приложение перезапрашивает список альбомов, удаляя из кэша альбомы, которых на сервере больше нет
### Requirement: Потеря доступа к альбому
Приложение SHALL переставать отображать альбом, удалённый владельцем или ставший
недоступным после исключения участника: альбом SHALL исчезать из локального кэша и
списка, а экран открытого альбома SHALL возвращаться к списку.
#### Scenario: Альбом удалён во время просмотра
- **WHEN** владелец удаляет альбом, пока участник просматривает экран этого альбома
- **THEN** экран перестаёт показывать альбом и возвращается к списку
#### Scenario: Участник исключён
- **WHEN** участника исключают из альбома
- **THEN** альбом исчезает из его списка, а открытый экран возвращается к списку
#### Scenario: Экран альбома не открыт
- **WHEN** альбом удалён, а его экран не открыт
- **THEN** карточка альбома исчезает из списка
#### Scenario: Отсутствие альбома в кэше без сети
- **WHEN** приложение открывает экран альбома, которого нет в кэше, и сеть недоступна
- **THEN** экран не считается потерявшим доступ и не закрывается сам
+19 -1
View File
@@ -116,12 +116,16 @@ comment.created, like.added, like.removed, member.joined, member.left, album.upd
#### Scenario: Обновление альбома из списка #### Scenario: Обновление альбома из списка
- **WHEN** приходит событие обновления для альбома, который уже есть в списке клиента - **WHEN** приходит событие обновления для альбома, который уже есть в списке клиента
- **THEN** клиент заменяет его на месте, не меняя порядок списка - **THEN** клиент заменяет альбом по полученному объекту без дополнительного запроса списка
#### Scenario: Удаление альбома из списка #### Scenario: Удаление альбома из списка
- **WHEN** приходит событие удаления для альбома, который есть в списке клиента - **WHEN** приходит событие удаления для альбома, который есть в списке клиента
- **THEN** клиент убирает альбом из списка - **THEN** клиент убирает альбом из списка
#### Scenario: Порядок списка
- **WHEN** клиент применяет событие к списку альбомов
- **THEN** порядок элементов списка определяется правилами отображения этого клиента
### Requirement: Догон состояния альбомов при переподключении ### Requirement: Догон состояния альбомов при переподключении
Клиент SHALL после восстановления персонального потока синхронизировать список альбомов Клиент SHALL после восстановления персонального потока синхронизировать список альбомов
через REST, чтобы отразить изменения, пропущенные во время разрыва соединения. через REST, чтобы отразить изменения, пропущенные во время разрыва соединения.
@@ -165,3 +169,17 @@ comment.created, like.added, like.removed, member.joined, member.left, album.upd
#### Scenario: Окончательное удаление фото #### Scenario: Окончательное удаление фото
- **WHEN** фото удаляется окончательно - **WHEN** фото удаляется окончательно
- **THEN** владелец и участники получают `album.updated` с уменьшенным `photos_count` - **THEN** владелец и участники получают `album.updated` с уменьшенным `photos_count`
### Requirement: Обновление альбома при готовности вариантов обложки
Когда для фото, являющегося обложкой альбома, сгенерированы варианты, система SHALL
публиковать `album.updated` с актуальным объектом альбома в персональные потоки владельца и
участников: только тогда объект получает ссылку на обложку. Готовность вариантов прочих фото
событий не порождает.
#### Scenario: Варианты обложки готовы
- **WHEN** для фото, которое является обложкой альбома, сгенерированы thumb и medium
- **THEN** владелец и участники получают `album.updated` с непустой ссылкой на обложку
#### Scenario: Варианты обычного фото готовы
- **WHEN** варианты готовы для фото, которое не является обложкой альбома
- **THEN** персональные потоки не получают `album.updated`