diff --git a/db/migrations/00006_playlist_shares.sql b/db/migrations/00006_playlist_shares.sql new file mode 100644 index 0000000..866e1a1 --- /dev/null +++ b/db/migrations/00006_playlist_shares.sql @@ -0,0 +1,3 @@ +-- +goose Up +-- 分享链接中的随机串,NULL 表示没有分享。明文保存:所有者随时可以再复制一次链接。 +ALTER TABLE playlists ADD COLUMN share_id text UNIQUE; diff --git a/db/queries/playlists.sql b/db/queries/playlists.sql index bf7ac8d..bd6f103 100644 --- a/db/queries/playlists.sql +++ b/db/queries/playlists.sql @@ -2,7 +2,7 @@ INSERT INTO playlists (owner_id, kind, name) VALUES (@owner_id, 'favorites', '我喜欢'); -- name: MemberPlaylists :many -SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, count(i.track_id)::bigint AS track_count +SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, p.share_id, count(i.track_id)::bigint AS track_count FROM playlists p LEFT JOIN playlist_items i ON i.playlist_id = p.id WHERE p.owner_id = @owner_id @@ -108,3 +108,33 @@ SELECT p.id FROM playlists p JOIN playlist_items i ON i.playlist_id = p.id WHERE p.owner_id = @owner_id AND i.source = @source AND i.track_id = @track_id ORDER BY p.id; + +-- name: SetPlaylistShare :execrows +UPDATE playlists SET share_id = @share_id WHERE id = @id AND owner_id = @owner_id; + +-- name: SharedPlaylist :one +SELECT p.id, p.kind, p.name, m.name AS owner_name, m.avatar_hash AS owner_avatar_hash +FROM playlists p +JOIN members m ON m.id = p.owner_id +WHERE p.share_id = sqlc.arg(share_id)::text; + +-- name: CopySharedPlaylistItems :one +-- 确认分享还在和复制曲目在同一条语句里,看到的是同一个快照:要么完整复制,要么读不到分享。不锁来源歌单:两个人同时复制 +-- 对方分享的“我喜欢”时,各自已经锁住了自己的“我喜欢”,再去锁对方的就会互相等待。 +WITH shared AS ( + SELECT id FROM playlists WHERE share_id = sqlc.arg(share_id)::text +), copied AS ( + INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position) + SELECT sqlc.arg(to_id), i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url, + row_number() OVER (ORDER BY i.position) + FROM playlist_items i + JOIN shared s ON s.id = i.playlist_id + RETURNING 1 +) +SELECT EXISTS (SELECT FROM shared) AS found, (SELECT count(*) FROM copied)::bigint AS copied; + +-- name: PlaylistCover :one +SELECT cover_url, cover_key FROM playlist_items +WHERE playlist_id = @playlist_id AND (cover_url IS NOT NULL OR cover_key IS NOT NULL) +ORDER BY position DESC +LIMIT 1; diff --git a/internal/adapter/out/postgres/internal/query/playlists.sql.go b/internal/adapter/out/postgres/internal/query/playlists.sql.go index 8820360..3e7fcb2 100644 --- a/internal/adapter/out/postgres/internal/query/playlists.sql.go +++ b/internal/adapter/out/postgres/internal/query/playlists.sql.go @@ -53,6 +53,39 @@ func (q *Queries) CopyPlaylistItems(ctx context.Context, arg CopyPlaylistItemsPa return result.RowsAffected(), nil } +const copySharedPlaylistItems = `-- name: CopySharedPlaylistItems :one +WITH shared AS ( + SELECT id FROM playlists WHERE share_id = $1::text +), copied AS ( + INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position) + SELECT $2, i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url, + row_number() OVER (ORDER BY i.position) + FROM playlist_items i + JOIN shared s ON s.id = i.playlist_id + RETURNING 1 +) +SELECT EXISTS (SELECT FROM shared) AS found, (SELECT count(*) FROM copied)::bigint AS copied +` + +type CopySharedPlaylistItemsParams struct { + ShareID string + ToID string +} + +type CopySharedPlaylistItemsRow struct { + Found bool + Copied int64 +} + +// 确认分享还在和复制曲目在同一条语句里,看到的是同一个快照:要么完整复制,要么读不到分享。不锁来源歌单:两个人同时复制 +// 对方分享的“我喜欢”时,各自已经锁住了自己的“我喜欢”,再去锁对方的就会互相等待。 +func (q *Queries) CopySharedPlaylistItems(ctx context.Context, arg CopySharedPlaylistItemsParams) (CopySharedPlaylistItemsRow, error) { + row := q.db.QueryRow(ctx, copySharedPlaylistItems, arg.ShareID, arg.ToID) + var i CopySharedPlaylistItemsRow + err := row.Scan(&i.Found, &i.Copied) + return i, err +} + const countPlaylistItems = `-- name: CountPlaylistItems :one SELECT count(*) FROM playlist_items WHERE playlist_id = $1 ` @@ -258,7 +291,7 @@ func (q *Queries) MemberPlaylistCovers(ctx context.Context, ownerID string) ([]M } const memberPlaylists = `-- name: MemberPlaylists :many -SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, count(i.track_id)::bigint AS track_count +SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, p.share_id, count(i.track_id)::bigint AS track_count FROM playlists p LEFT JOIN playlist_items i ON i.playlist_id = p.id WHERE p.owner_id = $1 @@ -272,6 +305,7 @@ type MemberPlaylistsRow struct { Name string CreatedAt time.Time UpdatedAt time.Time + ShareID *string TrackCount int64 } @@ -290,6 +324,7 @@ func (q *Queries) MemberPlaylists(ctx context.Context, ownerID string) ([]Member &i.Name, &i.CreatedAt, &i.UpdatedAt, + &i.ShareID, &i.TrackCount, ); err != nil { return nil, err @@ -302,6 +337,25 @@ func (q *Queries) MemberPlaylists(ctx context.Context, ownerID string) ([]Member return items, nil } +const playlistCover = `-- name: PlaylistCover :one +SELECT cover_url, cover_key FROM playlist_items +WHERE playlist_id = $1 AND (cover_url IS NOT NULL OR cover_key IS NOT NULL) +ORDER BY position DESC +LIMIT 1 +` + +type PlaylistCoverRow struct { + CoverURL *string + CoverKey *string +} + +func (q *Queries) PlaylistCover(ctx context.Context, playlistID string) (PlaylistCoverRow, error) { + row := q.db.QueryRow(ctx, playlistCover, playlistID) + var i PlaylistCoverRow + err := row.Scan(&i.CoverURL, &i.CoverKey) + return i, err +} + const playlistHasItem = `-- name: PlaylistHasItem :one SELECT EXISTS (SELECT FROM playlist_items WHERE playlist_id = $1 AND source = $2 AND track_id = $3) ` @@ -458,6 +512,52 @@ func (q *Queries) SetPlaylistSeq(ctx context.Context, arg SetPlaylistSeqParams) return err } +const setPlaylistShare = `-- name: SetPlaylistShare :execrows +UPDATE playlists SET share_id = $1 WHERE id = $2 AND owner_id = $3 +` + +type SetPlaylistShareParams struct { + ShareID *string + ID string + OwnerID string +} + +func (q *Queries) SetPlaylistShare(ctx context.Context, arg SetPlaylistShareParams) (int64, error) { + result, err := q.db.Exec(ctx, setPlaylistShare, arg.ShareID, arg.ID, arg.OwnerID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const sharedPlaylist = `-- name: SharedPlaylist :one +SELECT p.id, p.kind, p.name, m.name AS owner_name, m.avatar_hash AS owner_avatar_hash +FROM playlists p +JOIN members m ON m.id = p.owner_id +WHERE p.share_id = $1::text +` + +type SharedPlaylistRow struct { + ID string + Kind string + Name string + OwnerName string + OwnerAvatarHash *string +} + +func (q *Queries) SharedPlaylist(ctx context.Context, shareID string) (SharedPlaylistRow, error) { + row := q.db.QueryRow(ctx, sharedPlaylist, shareID) + var i SharedPlaylistRow + err := row.Scan( + &i.ID, + &i.Kind, + &i.Name, + &i.OwnerName, + &i.OwnerAvatarHash, + ) + return i, err +} + const touchPlaylist = `-- name: TouchPlaylist :exec UPDATE playlists SET updated_at = now() WHERE id = $1 ` diff --git a/internal/adapter/out/postgres/playlists.go b/internal/adapter/out/postgres/playlists.go index 3afdc0d..c5d2dbe 100644 --- a/internal/adapter/out/postgres/playlists.go +++ b/internal/adapter/out/postgres/playlists.go @@ -42,6 +42,7 @@ func (s *Store) MemberPlaylists(ctx context.Context, ownerID string) ([]port.Pla CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, Cover: covers[r.ID], + ShareID: orEmpty(r.ShareID), } } return lists, nil @@ -180,6 +181,10 @@ func (s *Store) PlaylistItems(ctx context.Context, ownerID, id string) ([]port.P if err != nil { return nil, fmt.Errorf("读取歌单 %s 的曲目:%w", id, err) } + return playlistItems(rows), nil +} + +func playlistItems(rows []query.PlaylistItem) []port.PlaylistItem { items := make([]port.PlaylistItem, len(rows)) for i, r := range rows { items[i] = port.PlaylistItem{ @@ -195,7 +200,7 @@ func (s *Store) PlaylistItems(ctx context.Context, ownerID, id string) ([]port.P }, } } - return items, nil + return items } func (s *Store) AddPlaylistItem(ctx context.Context, ownerID, id string, item port.PlaylistItem, limit int) error { @@ -355,6 +360,86 @@ func (s *Store) MovePlaylistItems(ctx context.Context, ownerID, id string, track return nil } +func (s *Store) SetPlaylistShare(ctx context.Context, ownerID, id, shareID string) error { + if !isUUID(id) { + return port.ErrPlaylistNotFound + } + n, err := s.q.SetPlaylistShare(ctx, query.SetPlaylistShareParams{ID: id, OwnerID: ownerID, ShareID: nullable(shareID)}) + if err == nil && n == 0 { + err = port.ErrPlaylistNotFound + } + if err != nil { + return fmt.Errorf("设置歌单 %s 的分享:%w", id, err) + } + return nil +} + +func (s *Store) SharedPlaylist(ctx context.Context, shareID string) (port.SharedPlaylist, error) { + var shared port.SharedPlaylist + // 歌单和曲目在同一个快照里读:分开读时,删除歌单可能恰好发生在两次读取之间,读到歌单还在、曲目却已经没了。 + err := pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { + q := s.q.WithTx(tx) + p, err := q.SharedPlaylist(ctx, shareID) + if errors.Is(err, pgx.ErrNoRows) { + return port.ErrShareNotFound + } + if err != nil { + return err + } + rows, err := q.PlaylistItems(ctx, p.ID) + if err != nil { + return err + } + shared = port.SharedPlaylist{ + Name: p.Name, + Favorites: p.Kind == favoritesKind, + OwnerName: p.OwnerName, + OwnerAvatarHash: orEmpty(p.OwnerAvatarHash), + Items: playlistItems(rows), + } + return nil + }) + if err != nil { + return port.SharedPlaylist{}, fmt.Errorf("读取分享的歌单:%w", err) + } + return shared, nil +} + +func (s *Store) CopySharedPlaylist(ctx context.Context, ownerID, shareID, name string, limit int) (port.PlaylistRecord, error) { + var record port.PlaylistRecord + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.q.WithTx(tx) + created, err := createPlaylist(ctx, q, ownerID, name, limit) + if err != nil { + return err + } + copied, err := q.CopySharedPlaylistItems(ctx, query.CopySharedPlaylistItemsParams{ShareID: shareID, ToID: created.ID}) + if err != nil { + return err + } + if !copied.Found { + return port.ErrShareNotFound + } + if err := q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: created.ID, ItemSeq: copied.Copied}); err != nil { + return err + } + record = port.PlaylistRecord{ID: created.ID, Name: name, TrackCount: int(copied.Copied), CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt} + cover, err := q.PlaylistCover(ctx, created.ID) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + record.Cover = &port.Cover{URL: orEmpty(cover.CoverURL), Key: track.ObjectKey(orEmpty(cover.CoverKey))} + return nil + }) + if err != nil { + return port.PlaylistRecord{}, fmt.Errorf("成员 %s 复制分享的歌单:%w", ownerID, err) + } + return record, nil +} + func splitTracks(tracks []track.Track) (sources, ids []string) { sources, ids = make([]string, len(tracks)), make([]string, len(tracks)) for i, t := range tracks { diff --git a/internal/adapter/out/postgres/playlists_test.go b/internal/adapter/out/postgres/playlists_test.go index 9e67fae..395c34c 100644 --- a/internal/adapter/out/postgres/playlists_test.go +++ b/internal/adapter/out/postgres/playlists_test.go @@ -689,3 +689,159 @@ func TestConcurrentImportsRespectLimit(t *testing.T) { t.Errorf("并发导入之后有 %d 个歌单, %v,期望正好 5 个(含“我喜欢”)", len(lists), err) } } + +func TestSharePlaylist(t *testing.T) { + store, _ := newStore(t) + alice := registered(t, store, "alice") + bob := registered(t, store, "bob") + hash := "0bc83cb571cd1c50ba6f3e8a78ef1346" + if _, err := store.UpdateMember(t.Context(), alice.ID, nil, &hash, nil); err != nil { + t.Fatal(err) + } + favorites := favoritesOf(t, store, alice.ID) + if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, sunnyItem, 1000); err != nil { + t.Fatal(err) + } + if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, clipItem, 1000); err != nil { + t.Fatal(err) + } + before := updatedAt(t, store, alice.ID, favorites) + if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, "share-1"); err != nil { + t.Fatal(err) + } + if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || lists[0].ShareID != "share-1" { + t.Errorf("歌单 = %#v, %v,期望带上分享 ID", lists, err) + } + if !updatedAt(t, store, alice.ID, favorites).Equal(before) { + t.Error("分享改变了歌单的更新时间") + } + shared, err := store.SharedPlaylist(t.Context(), "share-1") + want := port.SharedPlaylist{Name: "我喜欢", Favorites: true, OwnerName: alice.Name, OwnerAvatarHash: hash, Items: []port.PlaylistItem{clipItem, sunnyItem}} + if err != nil || shared.Name != want.Name || shared.Favorites != want.Favorites || shared.OwnerName != want.OwnerName || + shared.OwnerAvatarHash != want.OwnerAvatarHash || !slices.Equal(shared.Items, want.Items) { + t.Errorf("分享的歌单 = %#v, %v,期望 %#v", shared, err, want) + } + + if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, "share-2"); err != nil { + t.Fatal(err) + } + if _, err := store.SharedPlaylist(t.Context(), "share-1"); !errors.Is(err, port.ErrShareNotFound) { + t.Errorf("重新生成之后的旧链接 = %v,期望 ErrShareNotFound", err) + } + if shared, err := store.SharedPlaylist(t.Context(), "share-2"); err != nil || len(shared.Items) != 2 { + t.Errorf("新链接 = %#v, %v", shared, err) + } + for _, id := range []string{favorites, "not-a-uuid", "00000000-0000-7000-8000-000000000000"} { + if err := store.SetPlaylistShare(t.Context(), bob.ID, id, "share-3"); !errors.Is(err, port.ErrPlaylistNotFound) { + t.Errorf("分享别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err) + } + if err := store.SetPlaylistShare(t.Context(), bob.ID, id, ""); !errors.Is(err, port.ErrPlaylistNotFound) { + t.Errorf("停止分享别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err) + } + } + + for range 2 { + if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, ""); err != nil { + t.Fatalf("停止分享 = %v,期望重复停止也成功", err) + } + } + if _, err := store.SharedPlaylist(t.Context(), "share-2"); !errors.Is(err, port.ErrShareNotFound) { + t.Errorf("停止分享之后 = %v,期望 ErrShareNotFound", err) + } + if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || lists[0].ShareID != "" { + t.Errorf("停止分享之后的歌单 = %#v, %v,期望没有分享 ID", lists, err) + } +} + +func TestShareIDIsUnique(t *testing.T) { + store, _ := newStore(t) + alice := registered(t, store, "alice") + bob := registered(t, store, "bob") + if err := store.SetPlaylistShare(t.Context(), alice.ID, favoritesOf(t, store, alice.ID), "share-1"); err != nil { + t.Fatal(err) + } + if err := store.SetPlaylistShare(t.Context(), bob.ID, favoritesOf(t, store, bob.ID), "share-1"); err == nil { + t.Error("同一个分享 ID 设给第二个歌单时应报错") + } + if shared, err := store.SharedPlaylist(t.Context(), "share-1"); err != nil || shared.OwnerName != alice.Name { + t.Errorf("分享 = %#v, %v,期望仍是第一个歌单", shared, err) + } +} + +func TestDeletedPlaylistIsNoLongerShared(t *testing.T) { + store, _ := newStore(t) + alice := registered(t, store, "alice") + list := filled(t, store, alice.ID, "通勤", "1") + if err := store.SetPlaylistShare(t.Context(), alice.ID, list, "share-1"); err != nil { + t.Fatal(err) + } + if err := store.DeletePlaylist(t.Context(), alice.ID, list); err != nil { + t.Fatal(err) + } + if _, err := store.SharedPlaylist(t.Context(), "share-1"); !errors.Is(err, port.ErrShareNotFound) { + t.Errorf("删除歌单之后 = %v,期望 ErrShareNotFound", err) + } +} + +func TestCopySharedPlaylist(t *testing.T) { + store, _ := newStore(t) + alice := registered(t, store, "alice") + bob := registered(t, store, "bob") + source := filled(t, store, alice.ID, "通勤", "1", "2", "3") + if err := store.MovePlaylistItems(t.Context(), alice.ID, source, trackList("1"), &trackList("3")[0]); err != nil { + t.Fatal(err) + } + if err := store.SetPlaylistShare(t.Context(), alice.ID, source, "share-1"); err != nil { + t.Fatal(err) + } + original := displayIDs(t, store, alice.ID, source) + + copied, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-1", "抄来的", 50) + if err != nil { + t.Fatal(err) + } + if want := (port.Cover{URL: songItem("1").Meta.CoverURL}); copied.Name != "抄来的" || copied.Favorites || copied.TrackCount != 3 || + copied.ShareID != "" || copied.Cover == nil || *copied.Cover != want { + t.Errorf("复制返回 %#v,期望 3 首、封面是排在最前的 %+v、没有分享", copied, want) + } + lists, err := store.MemberPlaylists(t.Context(), bob.ID) + if err != nil || len(lists) != 2 { + t.Fatalf("歌单 = %#v, %v,期望“我喜欢”和复制的歌单", lists, err) + } + if read := lists[1]; read.ID != copied.ID || read.Name != copied.Name || read.TrackCount != 3 || read.ShareID != "" || + !read.CreatedAt.Equal(copied.CreatedAt) || !read.UpdatedAt.Equal(copied.UpdatedAt) { + t.Errorf("读出的 %#v,期望与复制返回的 %#v 一致", read, copied) + } + items, err := store.PlaylistItems(t.Context(), bob.ID, copied.ID) + sourceItems, _ := store.PlaylistItems(t.Context(), alice.ID, source) + if err != nil || !slices.Equal(items, sourceItems) { + t.Errorf("复制的曲目 = %#v, %v,期望与原歌单相同 %#v", items, err, sourceItems) + } + if err := store.AddPlaylistItem(t.Context(), bob.ID, copied.ID, songItem("9"), 1000); err != nil { + t.Fatal(err) + } + if got := displayIDs(t, store, bob.ID, copied.ID); !slices.Equal(got, append([]string{"9"}, original...)) { + t.Errorf("复制之后再加入 = %v,期望新加入的排在最前、其余保持 %v", got, original) + } + if got := displayIDs(t, store, alice.ID, source); !slices.Equal(got, original) { + t.Errorf("原歌单 = %v,期望不变 %v", got, original) + } + + empty := filled(t, store, alice.ID, "空的") + if err := store.SetPlaylistShare(t.Context(), alice.ID, empty, "share-2"); err != nil { + t.Fatal(err) + } + if copied, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-2", "空的", 50); err != nil || copied.TrackCount != 0 || copied.Cover != nil { + t.Errorf("复制空歌单 = %#v, %v,期望建出一个没有曲目的歌单", copied, err) + } + + if _, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-1", "超限", 3); !errors.Is(err, port.ErrPlaylistLimit) { + t.Errorf("歌单数到了上限 = %v,期望 ErrPlaylistLimit", err) + } + if _, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-x", "失效", 50); !errors.Is(err, port.ErrShareNotFound) { + t.Errorf("读不到的分享 = %v,期望 ErrShareNotFound", err) + } + if lists, err := store.MemberPlaylists(t.Context(), bob.ID); err != nil || len(lists) != 3 { + t.Errorf("失败之后有 %d 个歌单, %v,期望没有多建", len(lists), err) + } +} diff --git a/internal/app/port/playlist.go b/internal/app/port/playlist.go index 2fa9924..4e94ea4 100644 --- a/internal/app/port/playlist.go +++ b/internal/app/port/playlist.go @@ -12,6 +12,7 @@ var ( ErrPlaylistNotFound = errors.New("歌单不存在") ErrFavoritesFixed = errors.New("“我喜欢”不能重命名或删除") ErrPlaylistLimit = errors.New("歌单或歌单里的歌已达上限") + ErrShareNotFound = errors.New("分享已失效") ) type PlaylistRecord struct { @@ -22,6 +23,8 @@ type PlaylistRecord struct { CreatedAt time.Time UpdatedAt time.Time Cover *Cover + // 没有分享时为空。 + ShareID string } // 链接曲目的封面在对象存储中,只有对象键。 @@ -35,6 +38,16 @@ type PlaylistItem struct { Meta track.Meta } +// 分享页上显示的内容:不带所有者的成员 ID 和用户名。 +type SharedPlaylist struct { + Name string + Favorites bool + OwnerName string + OwnerAvatarHash string + // 新加入的在前。 + Items []PlaylistItem +} + // 所有方法都按 ownerID 过滤:别人的歌单和不存在的歌单一样,返回 ErrPlaylistNotFound。“我喜欢”由 Members.Register 创建。 type Playlists interface { // “我喜欢”在最前,其余按创建的先后。Cover 是歌单里排在最前、有封面的那首歌的封面,没有时为 nil。 @@ -62,4 +75,11 @@ type Playlists interface { CopyPlaylistItems(ctx context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error) // 按 playlist.Move 的规则调整顺序,错误原样返回。 MovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track, before *track.Track) error + // shareID 为空字符串时停止分享。shareID 已被其他歌单使用时返回错误。 + SetPlaylistShare(ctx context.Context, ownerID, id, shareID string) error + // 没有歌单使用这个 shareID 时返回 ErrShareNotFound。 + SharedPlaylist(ctx context.Context, shareID string) (SharedPlaylist, error) + // 新建 ownerID 的歌单,复制 shareID 对应歌单的全部曲目,顺序不变;返回的记录带曲目数和封面。已有 limit 个歌单(含“我喜欢”)时 + // 返回 ErrPlaylistLimit,没有歌单使用这个 shareID 时返回 ErrShareNotFound,两种情况都不新建歌单。 + CopySharedPlaylist(ctx context.Context, ownerID, shareID, name string, limit int) (PlaylistRecord, error) } diff --git a/internal/app/port/porttest/store.go b/internal/app/port/porttest/store.go index c70ce68..ce04906 100644 --- a/internal/app/port/porttest/store.go +++ b/internal/app/port/porttest/store.go @@ -553,6 +553,7 @@ type storedPlaylist struct { favorites bool items []port.PlaylistItem created, updated time.Time + shareID string } func (p *storedPlaylist) cover() *port.Cover { @@ -590,6 +591,7 @@ func (s *Store) MemberPlaylists(_ context.Context, ownerID string) ([]port.Playl CreatedAt: p.created, UpdatedAt: p.updated, Cover: p.cover(), + ShareID: p.shareID, } if p.favorites { lists = append([]port.PlaylistRecord{r}, lists...) @@ -698,11 +700,16 @@ func (s *Store) PlaylistItems(_ context.Context, ownerID, id string) ([]port.Pla if p == nil { return nil, port.ErrPlaylistNotFound } + return p.display(), nil +} + +// 新加入的在前。 +func (p *storedPlaylist) display() []port.PlaylistItem { items := make([]port.PlaylistItem, 0, len(p.items)) for i := len(p.items) - 1; i >= 0; i-- { items = append(items, p.items[i]) } - return items, nil + return items } func (s *Store) AddPlaylistItem(_ context.Context, ownerID, id string, item port.PlaylistItem, limit int) error { @@ -808,3 +815,64 @@ func (s *Store) MovePlaylistItems(_ context.Context, ownerID, id string, tracks p.updated = time.Now() return nil } + +func (s *Store) SetPlaylistShare(_ context.Context, ownerID, id, shareID string) error { + if err := checkText(shareID); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + p := s.playlist(ownerID, id) + if p == nil { + return port.ErrPlaylistNotFound + } + if other := s.shared(shareID); other != nil && other != p { + return fmt.Errorf("分享 ID %s 已被其他歌单使用:违反唯一约束", shareID) + } + p.shareID = shareID + return nil +} + +// 调用方持有 s.mu。 +func (s *Store) shared(shareID string) *storedPlaylist { + for _, p := range s.playlists { + if shareID != "" && p.shareID == shareID { + return p + } + } + return nil +} + +func (s *Store) SharedPlaylist(_ context.Context, shareID string) (port.SharedPlaylist, error) { + if err := checkText(shareID); err != nil { + return port.SharedPlaylist{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + p := s.shared(shareID) + if p == nil { + return port.SharedPlaylist{}, port.ErrShareNotFound + } + owner := s.member(p.owner).member + return port.SharedPlaylist{Name: p.name, Favorites: p.favorites, OwnerName: owner.Name, OwnerAvatarHash: owner.AvatarHash, Items: p.display()}, nil +} + +func (s *Store) CopySharedPlaylist(_ context.Context, ownerID, shareID, name string, limit int) (port.PlaylistRecord, error) { + if err := checkText(shareID, name); err != nil { + return port.PlaylistRecord{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + p, err := s.newPlaylist(ownerID, name, limit) + if err != nil { + return port.PlaylistRecord{}, err + } + from := s.shared(shareID) + if from == nil { + // 与 PostgreSQL 的事务一样先新建再复制,读不到分享时撤销新建的歌单。 + s.playlists = slices.DeleteFunc(s.playlists, func(other *storedPlaylist) bool { return other == p }) + return port.PlaylistRecord{}, port.ErrShareNotFound + } + p.items = slices.Clone(from.items) + return port.PlaylistRecord{ID: p.id, Name: name, TrackCount: len(p.items), CreatedAt: p.created, UpdatedAt: p.updated, Cover: p.cover()}, nil +} diff --git a/internal/app/port/porttest/store_test.go b/internal/app/port/porttest/store_test.go index 1539826..9bc259b 100644 --- a/internal/app/port/porttest/store_test.go +++ b/internal/app/port/porttest/store_test.go @@ -87,3 +87,28 @@ func TestImportPlaylistRejectsWhatPostgreSQLRejects(t *testing.T) { t.Errorf("失败之后的歌单 = %#v,期望一个也没有建", lists) } } + +func TestSetPlaylistShareRejectsDuplicates(t *testing.T) { + store := New() + m, err := store.CreateMember(t.Context(), []byte("token"), "甲") + if err != nil { + t.Fatal(err) + } + first, err := store.CreatePlaylist(t.Context(), m.ID, "一", 10) + if err != nil { + t.Fatal(err) + } + second, err := store.CreatePlaylist(t.Context(), m.ID, "二", 10) + if err != nil { + t.Fatal(err) + } + if err := store.SetPlaylistShare(t.Context(), m.ID, first.ID, "share"); err != nil { + t.Fatal(err) + } + if err := store.SetPlaylistShare(t.Context(), m.ID, second.ID, "share"); err == nil { + t.Error("同一个分享 ID 设给第二个歌单时应报错") + } + if err := store.SetPlaylistShare(t.Context(), m.ID, first.ID, "share"); err != nil { + t.Errorf("重新设成自己已有的分享 ID = %v,期望成功", err) + } +}