fix(realtime): доставлять обложку альбома после готовности вариантов

Карточка альбома не получала ссылку на обложку: она появляется в объекте
альбома только после генерации thumb/medium, а событие об этом шаге в
персональные потоки не публиковалось — счётчик обновлялся, обложка нет.

- albums.Service.NotifyCoverReady публикует album.updated получателям, когда
  варианты готовы у фото, являющегося текущей обложкой (иначе no-op, чтобы
  массовая загрузка не рассылала событие на каждый файл)
- photos: порт AlbumAccess расширен, worker вызывает публикацию после
  photo.updated
- project.md §11/§12: триггер добавлен в контракт персонального потока
This commit is contained in:
iu
2026-10-16 12:47:50 +03:00
parent 742745e6aa
commit 44cb7c7d68
7 changed files with 129 additions and 11 deletions
+17
View File
@@ -80,6 +80,23 @@ func (s *Service) NotifyAlbumChanged(ctx context.Context, albumID string) {
s.PublishAlbumTo(ctx, albumID, EventAlbumUpdated, s.albumAudience(ctx, albumID))
}
// NotifyCoverReady republishes the album to personal streams when the photo that
// is currently its cover gains its variant URLs. The album object only carries a
// cover link once thumb/medium exist, so without this event an album card keeps
// showing a placeholder. Readiness of any other photo does not change the album
// object and is ignored, which keeps bulk uploads from fanning out an event per
// file.
func (s *Service) NotifyCoverReady(ctx context.Context, albumID, photoID string) {
a, err := s.store.GetByID(ctx, albumID)
if err != nil {
return
}
if a.CoverPhotoID == nil || *a.CoverPhotoID != photoID {
return
}
s.NotifyAlbumChanged(ctx, albumID)
}
// albumAudience resolves the users an album is visible to. Resolution failures
// are swallowed: a missed fan-out is recovered by the client's reconnect sync
// and must not fail the mutation that triggered it.
+51 -1
View File
@@ -314,7 +314,57 @@ func TestNotifyAlbumChangedReachesRecipientsOnly(t *testing.T) {
assertNoEvent(t, albumSub)
}
// photos_count counts only photos outside the trash.
// Cover readiness republishes the album only when the photo that became ready is
// the current cover; any other photo leaves the album object unchanged.
func TestNotifyCoverReadyPublishesOnlyForTheCover(t *testing.T) {
ctx := context.Background()
st := newStack(t)
owner := seedUser(t, st, "Owner")
member := seedUser(t, st, "Member")
album, err := st.svc.Create(ctx, owner, albums.CreateInput{Title: "Cover"})
if err != nil {
t.Fatalf("create: %v", err)
}
if err := st.repos.Members.Add(ctx, album.ID, member, "member", &owner); err != nil {
t.Fatalf("add member: %v", err)
}
const coverID = "photo-cover"
if _, err := st.db.Exec(
`INSERT INTO photos (id, album_id, uploader_id) VALUES ($1, $2, $3)`,
coverID, album.ID, owner,
); err != nil {
t.Fatalf("seed cover photo: %v", err)
}
if _, err := st.db.Exec(
`UPDATE albums SET cover_photo_id = $1 WHERE id = $2`, coverID, album.ID,
); err != nil {
t.Fatalf("set cover: %v", err)
}
ownerSub := st.hub.SubscribeUser(owner)
defer ownerSub.Close()
memberSub := st.hub.SubscribeUser(member)
defer memberSub.Close()
// A photo that is not the cover changes nothing.
st.svc.NotifyCoverReady(ctx, album.ID, "photo-other")
assertNoEvent(t, ownerSub)
assertNoEvent(t, memberSub)
// The cover becoming ready reaches the owner and every member.
st.svc.NotifyCoverReady(ctx, album.ID, coverID)
for name, sub := range map[string]*sse.Subscription{"owner": ownerSub, "member": memberSub} {
ev := nextEvent(t, sub)
if ev.Type != albums.EventAlbumUpdated {
t.Fatalf("%s event = %q, want %q", name, ev.Type, albums.EventAlbumUpdated)
}
if a, ok := ev.Data.(*models.Album); !ok || a.ID != album.ID {
t.Fatalf("%s payload = %#v", name, ev.Data)
}
}
}
func TestPhotosCountExcludesTrash(t *testing.T) {
ctx := context.Background()
st := newStack(t)