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:
@@ -15,6 +15,9 @@ import (
|
||||
|
||||
type MemberLookup interface {
|
||||
Role(ctx context.Context, albumID, userID string) (string, error)
|
||||
// Audience resolves the users the album is visible to (owner + members) for
|
||||
// personal event fan-out.
|
||||
Audience(ctx context.Context, albumID string) ([]string, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -28,6 +31,69 @@ func NewService(store Repository, members MemberLookup, hub *sse.Hub, storage st
|
||||
return &Service{store: store, members: members, hub: hub, storage: storage}
|
||||
}
|
||||
|
||||
// Event names published to personal streams for the album lifecycle. They are
|
||||
// part of the client contract documented in project.md.
|
||||
const (
|
||||
EventAlbumCreated = "album.created"
|
||||
EventAlbumUpdated = "album.updated"
|
||||
EventAlbumDeleted = "album.deleted"
|
||||
)
|
||||
|
||||
// OwnerID returns the owner of the album, delegating to the store so callers
|
||||
// that only need the owner (members invites, role checks) can use this service
|
||||
// as their album reader.
|
||||
func (s *Service) OwnerID(ctx context.Context, albumID string) (string, error) {
|
||||
return s.store.OwnerID(ctx, albumID)
|
||||
}
|
||||
|
||||
// PublishAlbumTo fans an album lifecycle event out to the personal streams of
|
||||
// the given users. Events other than deletion carry the album re-read through
|
||||
// Store.GetByID and enriched with attachCover, so the payload has the same
|
||||
// shape as an element of GET /api/albums; deletion carries only {id} because
|
||||
// the album is already gone when it is published.
|
||||
func (s *Service) PublishAlbumTo(ctx context.Context, albumID, eventType string, userIDs []string) {
|
||||
if len(userIDs) == 0 || s.hub == nil {
|
||||
return
|
||||
}
|
||||
var payload any
|
||||
if eventType == EventAlbumDeleted {
|
||||
payload = map[string]string{"id": albumID}
|
||||
} else {
|
||||
a, err := s.store.GetByID(ctx, albumID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.attachCover(ctx, a)
|
||||
payload = a
|
||||
}
|
||||
for _, uid := range userIDs {
|
||||
s.hub.PublishUser(uid, eventType, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyAlbumChanged re-reads the album and publishes album.updated to the
|
||||
// personal streams of its owner and members, without touching the per-album
|
||||
// channel. Photo-composition changes use it: the open album page learns about
|
||||
// them from photo.* events, and a per-album album.updated would force a REST
|
||||
// reload for every photo.
|
||||
func (s *Service) NotifyAlbumChanged(ctx context.Context, albumID string) {
|
||||
s.PublishAlbumTo(ctx, albumID, EventAlbumUpdated, s.albumAudience(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.
|
||||
func (s *Service) albumAudience(ctx context.Context, albumID string) []string {
|
||||
if s.members == nil {
|
||||
return nil
|
||||
}
|
||||
ids, err := s.members.Audience(ctx, albumID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Title string
|
||||
Description string
|
||||
@@ -47,6 +113,7 @@ func (s *Service) Create(ctx context.Context, userID string, in CreateInput) (*m
|
||||
if err := s.store.Create(ctx, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.PublishAlbumTo(ctx, a.ID, EventAlbumCreated, s.albumAudience(ctx, a.ID))
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -152,7 +219,11 @@ func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateI
|
||||
a = refreshed
|
||||
}
|
||||
s.attachCover(ctx, a)
|
||||
s.hub.Publish(albumID, "album.updated", a)
|
||||
s.hub.Publish(albumID, EventAlbumUpdated, a)
|
||||
// The per-album event above keeps the mobile client and the open album page
|
||||
// working; the personal fan-out below reaches everyone whose album list
|
||||
// changes, whether or not they are looking at this album.
|
||||
s.PublishAlbumTo(ctx, albumID, EventAlbumUpdated, s.albumAudience(ctx, albumID))
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -164,12 +235,16 @@ func (s *Service) Delete(ctx context.Context, albumID, userID string) error {
|
||||
if role != roles.Owner {
|
||||
return httpx.Forbidden("forbidden", "only the owner can delete the album")
|
||||
}
|
||||
// The audience must be computed before deletion: album_members is removed by
|
||||
// cascade with the album, so the recipient list is unavailable afterwards.
|
||||
audience := s.albumAudience(ctx, a.ID)
|
||||
if err := s.store.TrashPhotosByAlbum(ctx, a.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.Delete(ctx, a.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID})
|
||||
s.hub.Publish(albumID, EventAlbumDeleted, map[string]string{"id": a.ID})
|
||||
s.PublishAlbumTo(ctx, albumID, EventAlbumDeleted, audience)
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user