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:
@@ -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` не выдаёт ошибок.
|
||||
@@ -619,3 +619,55 @@ SHALL показывать значками расположение содер
|
||||
#### Scenario: Не сопоставлено
|
||||
- **WHEN** сопоставления с серверным фото нет
|
||||
- **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** экран не считается потерявшим доступ и не закрывается сам
|
||||
|
||||
@@ -116,12 +116,16 @@ comment.created, like.added, like.removed, member.joined, member.left, album.upd
|
||||
|
||||
#### Scenario: Обновление альбома из списка
|
||||
- **WHEN** приходит событие обновления для альбома, который уже есть в списке клиента
|
||||
- **THEN** клиент заменяет его на месте, не меняя порядок списка
|
||||
- **THEN** клиент заменяет альбом по полученному объекту без дополнительного запроса списка
|
||||
|
||||
#### Scenario: Удаление альбома из списка
|
||||
- **WHEN** приходит событие удаления для альбома, который есть в списке клиента
|
||||
- **THEN** клиент убирает альбом из списка
|
||||
|
||||
#### Scenario: Порядок списка
|
||||
- **WHEN** клиент применяет событие к списку альбомов
|
||||
- **THEN** порядок элементов списка определяется правилами отображения этого клиента
|
||||
|
||||
### Requirement: Догон состояния альбомов при переподключении
|
||||
Клиент SHALL после восстановления персонального потока синхронизировать список альбомов
|
||||
через REST, чтобы отразить изменения, пропущенные во время разрыва соединения.
|
||||
@@ -165,3 +169,17 @@ comment.created, like.added, like.removed, member.joined, member.left, album.upd
|
||||
#### Scenario: Окончательное удаление фото
|
||||
- **WHEN** фото удаляется окончательно
|
||||
- **THEN** владелец и участники получают `album.updated` с уменьшенным `photos_count`
|
||||
|
||||
### Requirement: Обновление альбома при готовности вариантов обложки
|
||||
Когда для фото, являющегося обложкой альбома, сгенерированы варианты, система SHALL
|
||||
публиковать `album.updated` с актуальным объектом альбома в персональные потоки владельца и
|
||||
участников: только тогда объект получает ссылку на обложку. Готовность вариантов прочих фото
|
||||
событий не порождает.
|
||||
|
||||
#### Scenario: Варианты обложки готовы
|
||||
- **WHEN** для фото, которое является обложкой альбома, сгенерированы thumb и medium
|
||||
- **THEN** владелец и участники получают `album.updated` с непустой ссылкой на обложку
|
||||
|
||||
#### Scenario: Варианты обычного фото готовы
|
||||
- **WHEN** варианты готовы для фото, которое не является обложкой альбома
|
||||
- **THEN** персональные потоки не получают `album.updated`
|
||||
|
||||
Reference in New Issue
Block a user