Compare commits

..
19 Commits
Author SHA1 Message Date
DiheChen e539c7d78b feat: select several playlist songs to remove, save or reorder them and drag songs into place
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
2026-11-09 21:33:06 +08:00
DiheChen d73a161e84 feat: browse my playlists as a sortable list or a glowing cover grid and open each one 2026-11-09 21:32:21 +08:00
DiheChen ffd2b390f0 feat: save songs to my playlists from the player, queue and history 2026-11-09 21:31:18 +08:00
DiheChen ef4558bd65 feat: track member playlists in the web client 2026-11-09 21:30:19 +08:00
DiheChen c87b48f014 feat: tell which of my playlists contain a song 2026-11-09 21:29:43 +08:00
DiheChen 4a59c639cd feat: remove, copy and reorder several playlist songs at once 2026-11-09 21:28:12 +08:00
DiheChen a07ef81e3c feat: expose member playlists over HTTP 2026-11-09 21:27:41 +08:00
DiheChen ca6603a994 feat: manage member playlists with server-side track metadata 2026-11-09 21:26:41 +08:00
DiheChen 224ea02725 feat: store playlists with a fixed favorites list for every account 2026-11-09 21:25:18 +08:00
DiheChen f4e4d23831 feat: bind room tickets to member sessions and disconnect revoked sessions 2026-11-09 21:24:32 +08:00
DiheChen 83afee3142 feat: register and sign in from the settings page 2026-11-09 21:23:23 +08:00
DiheChen ec62be2d8d feat: switch registration and reset account passwords in the admin page 2026-11-09 21:22:40 +08:00
DiheChen 872904975d feat: expose account registration, login and admin recovery over HTTP 2026-11-09 21:21:23 +08:00
DiheChen b50e645d30 feat: register, log in and manage member accounts 2026-11-09 21:20:12 +08:00
DiheChen 7f67df3d41 feat: store whether registration is open 2026-11-09 21:19:37 +08:00
DiheChen a61df45c32 feat: store accounts and per-device sessions for members 2026-11-09 21:18:26 +08:00
DiheChen 90a3b26c77 feat: reject emails that are not shaped like name@example.com when updating a profile 2026-11-09 21:17:42 +08:00
DiheChen 729e5861f5 feat: take the client address from trusted proxies 2026-11-09 21:16:26 +08:00
DiheChen d17477ba59 feat: add a shared quota for account operations 2026-11-09 21:15:06 +08:00
53 changed files with 1271 additions and 170 deletions

No files matched your search

+12
View File
@@ -39,6 +39,18 @@ vim .env
# 然后使用原来的部署命令重新启动
```
`proxy` 网络已经存在时,docker compose 不会按 `compose.prod.yaml` 修改它的网段。如果下面的命令输出的不是 `172.31.250.0/24`(比如网络由没有固定网段的旧版创建),serve 就不会信任 traefik 转发的客户端地址,所有客户端会被算进同一个限流额度。这时先停止服务、删除这个网络,再用原来的部署命令启动:
```bash
docker network inspect proxy --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
docker compose \
-f compose.yaml \
-f compose.prod.yaml \
down
# down 已经删掉这个网络时,下面的命令会提示网络不存在,可以忽略
docker network rm proxy
```
修改源代码后,自行构建和启动:
```bash
+1 -4
View File
@@ -103,7 +103,7 @@ func (g *goDecoder) object(name string, s *schema) {
decode := "decodeString"
switch {
case p.Type == "array":
decode = fmt.Sprintf("decodeArray(decode%s)", refName(p.Items.Ref))
decode = fmt.Sprintf("decodeArray(decode%s, %d)", refName(p.Items.Ref), *p.MaxItems)
case p.Ref != "":
decode = "decode" + refName(p.Ref)
case p.Type == "integer":
@@ -154,9 +154,6 @@ func (g *goDecoder) checks(value, at string, s *schema, patternName string) {
if s.MinItems != nil {
check("checkMinItems", fmt.Sprint(*s.MinItems))
}
if s.MaxItems != nil {
check("checkMaxItems", fmt.Sprint(*s.MaxItems))
}
if s.Minimum != nil {
check("checkMinimum", fmt.Sprint(*s.Minimum))
}
+5 -2
View File
@@ -470,10 +470,13 @@ func (d *idl) checkInboundArray(where string, s *schema) error {
if s.Items.Ref == "" || d.Defs[refName(s.Items.Ref)] != nil && d.Defs[refName(s.Items.Ref)].scalar() {
return fmt.Errorf("%s:请求中的数组元素只能引用对象或枚举类型", where)
}
if s.MinItems != nil && *s.MinItems < 0 || s.MaxItems != nil && *s.MaxItems < 0 {
if s.MaxItems == nil {
return fmt.Errorf("%s:请求中的数组必须写 maxItems,解码时先按它拒绝过长的数组", where)
}
if s.MinItems != nil && *s.MinItems < 0 || *s.MaxItems < 0 {
return fmt.Errorf("%s:minItems 和 maxItems 不能是负数", where)
}
if s.MinItems != nil && s.MaxItems != nil && *s.MinItems > *s.MaxItems {
if s.MinItems != nil && *s.MinItems > *s.MaxItems {
return fmt.Errorf("%s:minItems 大于 maxItems", where)
}
return nil
+5
View File
@@ -154,6 +154,11 @@ func TestParseIDLRejects(t *testing.T) {
})
d["x-requests"] = []any{roomRequest(object(map[string]any{"batch": ref("Batch")}, "batch"))}
}, "$defs.Batch.properties.ids:数组必须是必填字段"},
{"请求数组没有 maxItems", func(d map[string]any) {
d["x-requests"] = []any{roomRequest(object(map[string]any{
"ids": map[string]any{"type": "array", "items": ref("Empty"), "minItems": 1},
}, "ids"))}
}, "请求中的数组必须写 maxItems"},
{"请求数组的 minItems 大于 maxItems", func(d map[string]any) {
d["x-requests"] = []any{roomRequest(object(map[string]any{
"ids": map[string]any{"type": "array", "items": ref("Empty"), "minItems": 2, "maxItems": 1},
+1 -4
View File
@@ -270,15 +270,12 @@ func decodeRemoveTracksRequest(raw json.RawMessage, path string) (RemoveTracksRe
if err = requireFields(m, path, "tracks"); err != nil {
return v, err
}
if v.Tracks, err = decodeArray(decodeTrack)(m["tracks"], field(path, "tracks")); err != nil {
if v.Tracks, err = decodeArray(decodeTrack, 3)(m["tracks"], field(path, "tracks")); err != nil {
return v, err
}
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
return v, err
}
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 3); err != nil {
return v, err
}
return v, nil
}
+2
View File
@@ -117,6 +117,8 @@ networks:
proxy:
name: proxy
# traefik 经这个网络转发请求,serve 默认信任这个网段传来的 X-Forwarded-For。与已有的 docker 网络冲突时改这里,并同步修改 ALISTEN_TRUSTED_PROXIES。
# 网络已经存在时 docker compose 不会按这里改它的网段:proxy 网络不是这个网段时(比如由没有固定网段的旧版创建),
# 先 down 停止服务,再 docker network rm proxy 删除这个网络,然后启动。
ipam:
config:
- subnet: 172.31.250.0/24
+7
View File
@@ -31,6 +31,13 @@ SELECT * FROM members WHERE username = @username;
-- name: SetMemberPassword :execrows
UPDATE members SET password_hash = @password_hash WHERE id = @id AND username IS NOT NULL;
-- name: LockAccountPassword :one
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的修改和重置依次进行,后一个读到的是前一个写入的密码哈希。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id AND username IS NOT NULL FOR NO KEY UPDATE;
-- name: MemberHasSession :one
SELECT EXISTS (SELECT FROM member_sessions WHERE token_hash = @token_hash AND member_id = @member_id);
-- name: LockMemberPassword :one
-- FOR SHARE 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id FOR SHARE;
+1 -1
View File
@@ -37,7 +37,7 @@ UPDATE playlists SET name = @name, updated_at = now() WHERE id = @id AND owner_i
DELETE FROM playlists WHERE id = @id AND owner_id = @owner_id AND kind = 'custom';
-- name: LockPlaylist :one
-- 加入曲目前锁住歌单:并发加入时依次检查上限、分配排序值。
-- 改动曲目前锁住歌单:并发加入时依次检查上限、分配排序值。删除歌单先锁歌单行再级联删除曲目,其他操作也要先锁歌单再动曲目,否则会互相死锁。
SELECT item_seq FROM playlists WHERE id = @id AND owner_id = @owner_id FOR UPDATE;
-- name: PlaylistHasItem :one
+15 -9
View File
@@ -34,8 +34,10 @@ import (
)
const (
maxBodyBytes = 64 << 10
requestDeadline = 3 * time.Minute
maxBodyBytes = 64 << 10
// 歌单的批量接口一次最多 1000 首,链接曲目的 ID 最长 2048 字节,每首再留 256 字节给来源、字段名和 JSON 的标点。
maxBatchBodyBytes = 1000 * (2048 + 256)
requestDeadline = 3 * time.Minute
)
type Services struct {
@@ -118,10 +120,10 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteDeleteMyPlaylist, srv.deleteMyPlaylist)
srv.handle(mux, protocol.RouteListMyPlaylistItems, srv.listMyPlaylistItems)
srv.handle(mux, protocol.RouteAddMyPlaylistItem, srv.addMyPlaylistItem)
srv.handle(mux, protocol.RouteRemoveMyPlaylistItems, srv.removeMyPlaylistItems)
srv.handleLimit(mux, protocol.RouteRemoveMyPlaylistItems, maxBatchBodyBytes, srv.removeMyPlaylistItems)
srv.handle(mux, protocol.RouteListMyTrackPlaylists, srv.listMyTrackPlaylists)
srv.handle(mux, protocol.RouteCopyMyPlaylistItems, srv.copyMyPlaylistItems)
srv.handle(mux, protocol.RouteMoveMyPlaylistItems, srv.moveMyPlaylistItems)
srv.handleLimit(mux, protocol.RouteCopyMyPlaylistItems, maxBatchBodyBytes, srv.copyMyPlaylistItems)
srv.handleLimit(mux, protocol.RouteMoveMyPlaylistItems, maxBatchBodyBytes, srv.moveMyPlaylistItems)
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
@@ -178,10 +180,14 @@ func SameOrigin(allowedOrigins []string, next http.Handler) http.Handler {
}
func (s *server) handle(mux *http.ServeMux, route protocol.Route, fn handlerFunc) {
s.handleLimit(mux, route, maxBodyBytes, fn)
}
func (s *server) handleLimit(mux *http.ServeMux, route protocol.Route, maxBody int64, fn handlerFunc) {
mux.HandleFunc(route.Pattern, func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), requestDeadline)
defer cancel()
ctx, resp, err := s.serve(ctx, w, r, route.Auth, func(ctx context.Context, c call) (any, error) {
ctx, resp, err := s.serve(ctx, w, r, route.Auth, maxBody, func(ctx context.Context, c call) (any, error) {
resp, err := fn(ctx, c)
if err != nil || !route.Location {
return resp, err
@@ -204,7 +210,7 @@ func (s *server) handle(mux *http.ServeMux, route protocol.Route, fn handlerFunc
// 先鉴权再读请求体:后台没有启用时,无论请求是否合法都返回 FORBIDDEN。返回的 ctx 带有凭证所属的成员和房间,
// 出错时的日志也要带上它们。
func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Request, auth protocol.Auth, fn handlerFunc) (logCtx context.Context, _ any, err error) {
func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Request, auth protocol.Auth, maxBody int64, fn handlerFunc) (logCtx context.Context, _ any, err error) {
defer func() {
if value := recover(); value != nil {
logCtx, err = ctx, s.panicked(ctx, r, value)
@@ -237,7 +243,7 @@ func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Reque
return ctx, nil, fmt.Errorf("不支持的凭证类型 %d", auth)
}
if r.Method != http.MethodGet && r.Method != http.MethodDelete {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
if err != nil {
return ctx, nil, bodyError(r, err)
}
@@ -344,7 +350,7 @@ func errorInfo(err error) (info protocol.ErrorInfo, expected bool) {
}
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: "请求体不能超过 64 KiB"}, true
return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: fmt.Sprintf("请求体不能超过 %d KiB", tooLarge.Limit>>10)}, true
}
var roomErr *room.Error
if errors.As(err, &roomErr) {
+53
View File
@@ -22,6 +22,7 @@ import (
"github.com/dihemoe/alisten/internal/app/admin"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/lobby"
"github.com/dihemoe/alisten/internal/app/playlist"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/app/realtime"
@@ -154,6 +155,58 @@ func TestRequestBodyLimit(t *testing.T) {
}
}
func TestPlaylistBatchBodyLimit(t *testing.T) {
store := porttest.New()
s := services(store, store, "")
s.Playlists = playlist.New(store, store, noTracks{}, s.Catalog, nil)
server, _ := start(t, s)
var created protocol.CreateMemberResponse
if err := json.Unmarshal([]byte(send(t, server, http.MethodPost, "/api/members", "{}").body), &created); err != nil {
t.Fatal(err)
}
if err := store.Register(t.Context(), created.Member.ID, "alice", "hash"); err != nil {
t.Fatal(err)
}
auth := []string{"Authorization", "Bearer " + created.MemberToken}
lists, err := store.MemberPlaylists(t.Context(), created.Member.ID)
if err != nil {
t.Fatal(err)
}
favorites := lists[0].ID
other, err := store.CreatePlaylist(t.Context(), created.Member.ID, "通勤", 50)
if err != nil {
t.Fatal(err)
}
tracks := make([]string, 1000)
for i := range tracks {
tracks[i] = fmt.Sprintf(`{"source":"link","id":"https://www.youtube.com/watch?v=%011d&list=PL%032d"}`, i, i)
}
list := "[" + strings.Join(tracks, ",") + "]"
if len(list) <= 64<<10 {
t.Fatalf("1000 首链接只有 %d 字节,测不出 64 KiB 的上限", len(list))
}
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/removals", `{"tracks":`+list+`}`, auth...); got.status != http.StatusNoContent {
t.Errorf("移出 1000 首链接 = %#v,期望成功", got)
}
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/moves", `{"tracks":`+list+`}`, auth...); got.status != http.StatusNoContent {
t.Errorf("移动 1000 首链接 = %#v,期望成功", got)
}
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+other.ID+"/copies", `{"from":"`+favorites+`","tracks":`+list+`}`, auth...); got != (response{status: http.StatusOK, body: `{"added":0}`}) {
t.Errorf("复制 1000 首链接 = %#v,期望成功", got)
}
tooLarge := `{"tracks":[]}` + strings.Repeat(" ", maxBatchBodyBytes)
want := failure(protocol.ErrorCodeInvalidArgument, fmt.Sprintf("请求体不能超过 %d KiB", maxBatchBodyBytes>>10))
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/removals", tooLarge, auth...); got != want {
t.Errorf("超过批量上限的请求体 = %#v,期望 %#v", got, want)
}
want = failure(protocol.ErrorCodeInvalidArgument, "请求体不能超过 64 KiB")
if got := send(t, server, http.MethodPost, "/api/me/playlist-memberships", `{"tracks":`+list+`}`, auth...); got != want {
t.Errorf("其他接口超过 64 KiB 的请求体 = %#v,期望 %#v", got, want)
}
}
func TestMalformedPathParametersAreNotFound(t *testing.T) {
store := porttest.New()
server, logs := start(t, services(store, store, ""))
+1 -1
View File
@@ -41,7 +41,7 @@ type streamClose struct {
func (s *server) roomEvents(w http.ResponseWriter, r *http.Request) {
// 打开事件流的期限与 WebSocket 的握手相同,打开之后的推送不设期限。
setup, cancel := context.WithTimeout(r.Context(), helloTimeout)
ctx, opened, err := s.serve(setup, w, r, protocol.AuthTicket, s.observe)
ctx, opened, err := s.serve(setup, w, r, protocol.AuthTicket, maxBodyBytes, s.observe)
cancel()
if err != nil {
s.fail(ctx, w, r, err)
@@ -105,6 +105,18 @@ func (q *Queries) DeleteSession(ctx context.Context, tokenHash []byte) (int64, e
return result.RowsAffected(), nil
}
const lockAccountPassword = `-- name: LockAccountPassword :one
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 AND username IS NOT NULL FOR NO KEY UPDATE
`
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的修改和重置依次进行,后一个读到的是前一个写入的密码哈希。
func (q *Queries) LockAccountPassword(ctx context.Context, id string) (string, error) {
row := q.db.QueryRow(ctx, lockAccountPassword, id)
var password_hash string
err := row.Scan(&password_hash)
return password_hash, err
}
const lockMemberPassword = `-- name: LockMemberPassword :one
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 FOR SHARE
`
@@ -152,6 +164,22 @@ func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member,
return i, err
}
const memberHasSession = `-- name: MemberHasSession :one
SELECT EXISTS (SELECT FROM member_sessions WHERE token_hash = $1 AND member_id = $2)
`
type MemberHasSessionParams struct {
TokenHash []byte
MemberID string
}
func (q *Queries) MemberHasSession(ctx context.Context, arg MemberHasSessionParams) (bool, error) {
row := q.db.QueryRow(ctx, memberHasSession, arg.TokenHash, arg.MemberID)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const registerMember = `-- name: RegisterMember :execrows
UPDATE members SET username = $1, password_hash = $2
WHERE id = $3 AND username IS NULL
@@ -201,7 +201,7 @@ type LockPlaylistParams struct {
OwnerID string
}
// 加入曲目前锁住歌单:并发加入时依次检查上限、分配排序值。
// 改动曲目前锁住歌单:并发加入时依次检查上限、分配排序值。删除歌单先锁歌单行再级联删除曲目,其他操作也要先锁歌单再动曲目,否则会互相死锁。
func (q *Queries) LockPlaylist(ctx context.Context, arg LockPlaylistParams) (int64, error) {
row := q.db.QueryRow(ctx, lockPlaylist, arg.ID, arg.OwnerID)
var item_seq int64
+45 -10
View File
@@ -138,18 +138,11 @@ func (s *Store) DeleteSession(ctx context.Context, tokenHash []byte) error {
return nil
}
func (s *Store) SetPassword(ctx context.Context, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
func (s *Store) SetPassword(ctx context.Context, memberID, passwordHash string) ([][]byte, error) {
var deleted [][]byte
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
n, err := q.SetMemberPassword(ctx, query.SetMemberPasswordParams{ID: memberID, PasswordHash: &passwordHash})
if err != nil {
return err
}
if n == 0 {
return port.ErrMemberNotFound
}
deleted, err = q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
var err error
deleted, err = setPassword(ctx, s.q.WithTx(tx), memberID, passwordHash, nil)
return err
})
if err != nil {
@@ -157,3 +150,45 @@ func (s *Store) SetPassword(ctx context.Context, memberID, passwordHash string,
}
return deleted, nil
}
func (s *Store) ChangePassword(ctx context.Context, memberID, currentPasswordHash, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
var deleted [][]byte
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
// 先锁住成员行,再用新的语句查会话:并发的修改或重置提交之后才开始查,才能看到它删掉的会话。
current, err := q.LockAccountPassword(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrMemberNotFound
}
if err != nil {
return err
}
kept, err := q.MemberHasSession(ctx, query.MemberHasSessionParams{TokenHash: keepTokenHash, MemberID: memberID})
if err != nil {
return err
}
if !kept {
return port.ErrSessionNotFound
}
if current != currentPasswordHash {
return port.ErrAccountPasswordChanged
}
deleted, err = setPassword(ctx, q, memberID, passwordHash, keepTokenHash)
return err
})
if err != nil {
return nil, fmt.Errorf("修改成员 %s 的密码:%w", memberID, err)
}
return deleted, nil
}
func setPassword(ctx context.Context, q *query.Queries, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
n, err := q.SetMemberPassword(ctx, query.SetMemberPasswordParams{ID: memberID, PasswordHash: &passwordHash})
if err != nil {
return nil, err
}
if n == 0 {
return nil, port.ErrMemberNotFound
}
return q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
}
+97 -8
View File
@@ -5,6 +5,8 @@ import (
"errors"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
)
@@ -226,12 +228,12 @@ func TestMemberSessions(t *testing.T) {
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
t.Fatal(err)
}
deleted, err := store.SetPassword(t.Context(), alice.ID, "new", tokenHash("alice-1"))
deleted, err := store.ChangePassword(t.Context(), alice.ID, "old", "new", tokenHash("alice-1"))
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
t.Errorf("SetPassword 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
t.Errorf("ChangePassword 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
}
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "alice-3") != "" {
t.Error("SetPassword 应只留下指定的会话")
t.Error("ChangePassword 应只留下发出请求的会话")
}
if err := store.DeleteSession(t.Context(), tokenHash("alice-1")); err != nil {
t.Fatal(err)
@@ -244,9 +246,12 @@ func TestMemberSessions(t *testing.T) {
func TestSetPassword(t *testing.T) {
store, _ := newStore(t)
guest := createMember(t, store, "guest", "游客-1a2b")
if _, err := store.SetPassword(t.Context(), guest.ID, "hash", nil); !errors.Is(err, port.ErrMemberNotFound) {
if _, err := store.SetPassword(t.Context(), guest.ID, "hash"); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("给游客设置密码 = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.ChangePassword(t.Context(), guest.ID, "", "hash", tokenHash("guest")); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("游客修改密码 = %v,期望 ErrMemberNotFound", err)
}
alice := createMember(t, store, "alice-1", "游客-3c4d")
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
t.Fatal(err)
@@ -255,8 +260,8 @@ func TestSetPassword(t *testing.T) {
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-2"), "old"); err != nil {
t.Fatal(err)
}
if deleted, err := store.SetPassword(t.Context(), alice.ID, "new", tokenHash("alice-1")); err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-2")}) {
t.Errorf("SetPassword 返回 %x, %v,期望被删除的 alice-2", deleted, err)
if deleted, err := store.ChangePassword(t.Context(), alice.ID, "old", "new", tokenHash("alice-1")); err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-2")}) {
t.Errorf("ChangePassword 返回 %x, %v,期望被删除的 alice-2", deleted, err)
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "new" {
t.Errorf("修改之后的密码哈希 = %q, %v,期望 new", account.PasswordHash, err)
@@ -264,11 +269,95 @@ func TestSetPassword(t *testing.T) {
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != "" {
t.Error("修改密码应只留下指定的会话")
}
if deleted, err := store.SetPassword(t.Context(), alice.ID, "newer", nil); err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-1")}) {
if deleted, err := store.SetPassword(t.Context(), alice.ID, "newer"); err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-1")}) {
t.Errorf("SetPassword 返回 %x, %v,期望被删除的 alice-1", deleted, err)
}
if got := ownerOf(t, store, "alice-1"); got != "" {
t.Errorf("不指定保留的会话时应删除全部会话,alice-1 仍属于 %s", got)
t.Errorf("SetPassword 应删除全部会话,alice-1 仍属于 %s", got)
}
}
func TestChangePasswordChecksSessionAndPassword(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
createMember(t, store, "bob", "游客-5e6f")
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h1"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name, current, keep string
want error
}{
{"发出请求的会话已经不存在", "h1", "gone", port.ErrSessionNotFound},
{"会话属于别的成员", "h1", "bob", port.ErrSessionNotFound},
{"密码哈希已经变了", "h0", "alice-1", port.ErrAccountPasswordChanged},
} {
if _, err := store.ChangePassword(t.Context(), alice.ID, tc.current, "h2", tokenHash(tc.keep)); !errors.Is(err, tc.want) {
t.Errorf("%s时 = %v,期望 %v", tc.name, err, tc.want)
}
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "h1" {
t.Errorf("失败之后的密码哈希 = %q, %v,期望不变的 h1", account.PasswordHash, err)
}
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != alice.ID {
t.Error("失败时不应删除任何会话")
}
}
// 模拟 SetPassword 或另一次 ChangePassword 的事务,先不提交:改密码哈希,删除 keep 以外的会话,keep 为 nil 时全部删除。
func changePasswordMeanwhile(t *testing.T, store *Store, pool *pgxpool.Pool, memberID string, keep []byte, want error) {
t.Helper()
tx, err := pool.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(t.Context()) }()
if _, err := tx.Exec(t.Context(), "UPDATE members SET password_hash = 'h2' WHERE id = $1", memberID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE member_id = $1 AND token_hash IS DISTINCT FROM $2", memberID, keep); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
_, err := store.ChangePassword(t.Context(), memberID, "h1", "h3", tokenHash("alice-1"))
result <- err
}()
waitForLockWait(t, pool, 1)
if err := tx.Commit(t.Context()); err != nil {
t.Fatal(err)
}
if err := <-result; !errors.Is(err, want) {
t.Errorf("另一次修改提交之后,ChangePassword = %v,期望 %v", err, want)
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "h2" {
t.Errorf("密码哈希 = %q, %v,期望先提交的那次写入的 h2", account.PasswordHash, err)
}
}
func TestChangePasswordWaitsForReset(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
changePasswordMeanwhile(t, store, pool, alice.ID, nil, port.ErrSessionNotFound)
}
func TestChangePasswordWaitsForSameSessionChange(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
changePasswordMeanwhile(t, store, pool, alice.ID, tokenHash("alice-1"), port.ErrAccountPasswordChanged)
if ownerOf(t, store, "alice-1") != alice.ID {
t.Error("同一个会话先改过密码,这个会话应仍然有效")
}
}
+12 -4
View File
@@ -199,11 +199,19 @@ func (s *Store) AddPlaylistItem(ctx context.Context, ownerID, id string, item po
}
func (s *Store) RemovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track) error {
err := s.owned(ctx, ownerID, id)
if err == nil {
sources, ids := splitTracks(tracks)
err = s.q.DeletePlaylistItems(ctx, query.DeletePlaylistItemsParams{PlaylistID: id, Sources: sources, TrackIds: ids})
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
if _, err := q.LockPlaylist(ctx, query.LockPlaylistParams{ID: id, OwnerID: ownerID}); errors.Is(err, pgx.ErrNoRows) {
return port.ErrPlaylistNotFound
} else if err != nil {
return err
}
sources, ids := splitTracks(tracks)
return q.DeletePlaylistItems(ctx, query.DeletePlaylistItemsParams{PlaylistID: id, Sources: sources, TrackIds: ids})
})
if err != nil {
return fmt.Errorf("从歌单 %s 移除 %d 首歌:%w", id, len(tracks), err)
}
@@ -438,6 +438,71 @@ func TestConcurrentCrossCopiesDoNotDeadlock(t *testing.T) {
}
}
func TestConcurrentRemovalsAndMovesDoNotDeadlock(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
ids := make([]string, 40)
for i := range ids {
ids[i] = strconv.Itoa(i)
}
list := filled(t, store, alice.ID, "通勤", ids...)
errs := make(chan error, 40)
var wg sync.WaitGroup
for i := range 20 {
wg.Go(func() { errs <- store.RemovePlaylistItems(t.Context(), alice.ID, list, trackList(ids[i])) })
wg.Go(func() { errs <- store.MovePlaylistItems(t.Context(), alice.ID, list, trackList(ids[20+i]), nil) })
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Errorf("并发移出和移动 = %v,期望都成功", err)
}
}
got := displayIDs(t, store, alice.ID, list)
slices.Sort(got)
want := slices.Clone(ids[20:])
slices.Sort(want)
if !slices.Equal(got, want) {
t.Errorf("并发移出和移动之后有 %v,期望只剩没有移出的 %v", got, want)
}
}
func TestConcurrentRemovalsAndDeletesDoNotDeadlock(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
lists := make([]string, 20)
for i := range lists {
lists[i] = filled(t, store, alice.ID, "歌单"+strconv.Itoa(i), "1", "2", "3")
}
removed, deleted := make(chan error, len(lists)), make(chan error, len(lists))
var wg sync.WaitGroup
for _, list := range lists {
wg.Go(func() { removed <- store.RemovePlaylistItems(t.Context(), alice.ID, list, trackList("1", "2", "3")) })
wg.Go(func() { deleted <- store.DeletePlaylist(t.Context(), alice.ID, list) })
}
wg.Wait()
close(removed)
close(deleted)
for err := range removed {
if err != nil && !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("与删除歌单并发移出 = %v,期望成功或 ErrPlaylistNotFound", err)
}
}
for err := range deleted {
if err != nil {
t.Errorf("与移出并发删除歌单 = %v,期望成功", err)
}
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 1 {
t.Errorf("删除之后的歌单 = %#v, %v,期望只剩“我喜欢”", lists, err)
}
var count int
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM playlist_items`).Scan(&count); err != nil || count != 0 {
t.Errorf("删除歌单之后还有 %d 首曲目, %v,期望 0", count, err)
}
}
func TestTrackPlaylistsOnlyListsOwnPlaylists(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
@@ -86,9 +86,9 @@ func TestTicketsBelongToSessions(t *testing.T) {
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
t.Fatal(err)
}
deleted, err := store.SetPassword(t.Context(), alice.ID, "new", tokenHash("alice-1"))
deleted, err := store.ChangePassword(t.Context(), alice.ID, "old", "new", tokenHash("alice-1"))
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
t.Errorf("SetPassword 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
t.Errorf("ChangePassword 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
}
if !valid("alice-1") || valid("alice-2") || valid("alice-3") {
t.Error("删除会话之后,只有保留的会话的票据仍然有效")
+26 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"log/slog"
"net/netip"
"time"
"github.com/dihemoe/alisten/internal/app/port"
@@ -70,7 +71,7 @@ func (a *Accounts) Register(ctx context.Context, memberID, clientIP, username, p
if !settings.RegistrationOpen {
return Account{}, ErrRegistrationClosed
}
if err := a.take(ctx, "register:ip:"+clientIP, registerBurst, registerRefill); err != nil {
if err := a.take(ctx, "register:ip:"+quotaAddr(clientIP), registerBurst, registerRefill); err != nil {
return Account{}, err
}
hash, err := HashPassword(ctx, password)
@@ -91,7 +92,7 @@ func (a *Accounts) Login(ctx context.Context, current member.Member, currentToke
return Session{}, Account{}, ErrAlreadyLoggedIn
}
// 按 IP 限制一个来源的尝试次数,按用户名限制一个账号被尝试的次数:换着 IP 猜同一个账号也会受限。
if err := a.take(ctx, "login:ip:"+clientIP, loginIPBurst, loginIPRefill); err != nil {
if err := a.take(ctx, "login:ip:"+quotaAddr(clientIP), loginIPBurst, loginIPRefill); err != nil {
return Session{}, Account{}, err
}
if err := a.take(ctx, "login:user:"+username, loginUserBurst, loginUserRefill); err != nil {
@@ -169,7 +170,8 @@ func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentP
if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil {
return err
}
if _, err := a.verify(ctx, username, currentPassword); errors.Is(err, ErrInvalidCredentials) {
account, err := a.verify(ctx, username, currentPassword)
if errors.Is(err, ErrInvalidCredentials) {
return ErrWrongCurrentPassword
} else if err != nil {
return err
@@ -178,8 +180,14 @@ func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentP
if err != nil {
return err
}
deleted, err := a.members.SetPassword(ctx, memberID, hash, hashSecret(token))
if err != nil {
// 校验之后密码可能被重置或被别的设备改过:发出请求的会话已经被删掉时按 token 失效处理,同一个会话先改过时当前密码已经不对。
deleted, err := a.members.ChangePassword(ctx, memberID, account.PasswordHash, hash, hashSecret(token))
switch {
case errors.Is(err, port.ErrSessionNotFound):
return ErrInvalidMemberToken
case errors.Is(err, port.ErrAccountPasswordChanged):
return ErrWrongCurrentPassword
case err != nil:
return err
}
return a.gateway.RevokeSessions(ctx, deleted)
@@ -205,7 +213,7 @@ func (a *Accounts) ResetPassword(ctx context.Context, username string) (string,
if err != nil {
return "", err
}
deleted, err := a.members.SetPassword(ctx, account.Member.ID, hash, nil)
deleted, err := a.members.SetPassword(ctx, account.Member.ID, hash)
if err != nil {
return "", err
}
@@ -215,6 +223,18 @@ func (a *Accounts) ResetPassword(ctx context.Context, username string) (string,
return password, nil
}
// IPv6 客户端通常分到整个 /64,可以在里面随意换地址,所以按 /64 计数。clientIP 由适配器给出,解析不了时原样使用。
func quotaAddr(clientIP string) string {
addr, err := netip.ParseAddr(clientIP)
if err != nil {
return clientIP
}
if addr = addr.Unmap(); addr.Is4() {
return addr.String()
}
return netip.PrefixFrom(addr, 64).Masked().String()
}
func (a *Accounts) take(ctx context.Context, key string, burst int, refill time.Duration) error {
wait, err := a.quota.Take(ctx, key, burst, refill)
if err != nil {
+84 -1
View File
@@ -281,6 +281,39 @@ func TestLoginRejected(t *testing.T) {
}
}
func TestAccountQuotaCountsIPv6ByNetwork(t *testing.T) {
a, s, _, q, _ := newAccounts(t)
guest := createMember(t, s)
q.exhausted["login:ip:2001:db8:1:2::/64"] = true
for _, tc := range []struct {
ip, key string
want error
}{
{"2001:db8:1:2::1", "login:ip:2001:db8:1:2::/64", ErrTooManyAttempts},
{"2001:db8:1:2:aaaa:bbbb:cccc:dddd", "login:ip:2001:db8:1:2::/64", ErrTooManyAttempts},
{"2001:db8:1:3::1", "login:ip:2001:db8:1:3::/64", ErrInvalidCredentials},
{"203.0.113.7", "login:ip:203.0.113.7", ErrInvalidCredentials},
{"::ffff:203.0.113.7", "login:ip:203.0.113.7", ErrInvalidCredentials},
} {
q.taken = nil
_, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, tc.ip, "nobody", "wrong horse")
if !errors.Is(err, tc.want) {
t.Errorf("从 %s 登录 = %v,期望 %v", tc.ip, err, tc.want)
}
if len(q.taken) == 0 || q.taken[0] != tc.key {
t.Errorf("从 %s 登录取的额度 = %v,期望先取 %s", tc.ip, q.taken, tc.key)
}
}
q.taken = nil
if _, err := a.Register(t.Context(), guest.Member.ID, "2001:db8:1:2:aaaa:bbbb:cccc:dddd", "alice", "correct horse"); err != nil {
t.Fatal(err)
}
if want := []string{"register:ip:2001:db8:1:2::/64"}; !slices.Equal(q.taken, want) {
t.Errorf("从 IPv6 地址注册取的额度 = %v,期望 %v", q.taken, want)
}
}
func TestLogout(t *testing.T) {
a, s, store, _, revoked := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
@@ -463,7 +496,7 @@ type passwordChangedMembers struct {
}
func (s passwordChangedMembers) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
if _, err := s.SetPassword(ctx, memberID, "changed", nil); err != nil {
if _, err := s.SetPassword(ctx, memberID, "changed"); err != nil {
return err
}
return s.Store.ReplaceSession(ctx, oldTokenHash, memberID, newTokenHash, passwordHash)
@@ -491,6 +524,56 @@ func TestLoginRejectedWhenGuestSessionOrPasswordChangesMeanwhile(t *testing.T) {
}
}
// 校验当前密码读到旧的密码哈希之后,先执行 meanwhile 再继续。
type passwordReadMembers struct {
*porttest.Store
meanwhile func()
}
func (s passwordReadMembers) AccountByUsername(ctx context.Context, username string) (port.Account, error) {
account, err := s.Store.AccountByUsername(ctx, username)
s.meanwhile()
return account, err
}
func TestChangePasswordRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
node := porttest.NewRedis().Node("a")
racing := func(meanwhile func()) *Accounts {
return NewAccounts(passwordReadMembers{store, meanwhile}, store, &fakeQuota{exhausted: map[string]bool{}}, newGateway(store, node, node), slog.New(slog.DiscardHandler))
}
var reset string
resetting := racing(func() {
var err error
if reset, err = a.ResetPassword(t.Context(), "alice"); err != nil {
t.Fatal(err)
}
})
if err := resetting.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); !errors.Is(err, ErrInvalidMemberToken) {
t.Errorf("校验当前密码之后后台重置了密码,ChangePassword = %v,期望 ErrInvalidMemberToken", err)
}
guest := createMember(t, s)
session, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", reset)
if err != nil {
t.Fatalf("用重置出来的密码登录 = %v,期望成功", err)
}
changing := racing(func() {
if err := a.ChangePassword(t.Context(), session.Member.ID, session.MemberToken, reset, "first change"); err != nil {
t.Fatal(err)
}
})
if err := changing.ChangePassword(t.Context(), session.Member.ID, session.MemberToken, reset, "second change"); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("校验当前密码之后同一个会话已经改了密码,ChangePassword = %v,期望 ErrWrongCurrentPassword", err)
}
next := createMember(t, s)
if _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "first change"); err != nil {
t.Errorf("用先完成的那次修改的密码登录 = %v,期望成功", err)
}
}
// 名额占满、请求已经取消时,要做密码计算的请求以 context.Canceled 结束,不做的直接返回结果。
func TestLoginDoesPasswordWorkForUnknownUsername(t *testing.T) {
a, s, _, _, _ := newAccounts(t)
+5 -3
View File
@@ -46,7 +46,9 @@ type Members interface {
// 修改时对会话的删除已经执行,此时创建的会话不会再被删除。两种情况都不做任何修改。
ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error
DeleteSession(ctx context.Context, tokenHash []byte) error
// 在同一个事务中修改已注册成员的密码哈希,并删除该成员除 keepTokenHash 以外的会话,keepTokenHash 为 nil 时全部删除。
// 返回被删除的会话的 token 哈希。成员是游客时返回 ErrMemberNotFound。
SetPassword(ctx context.Context, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error)
// 在同一个事务中修改已注册成员的密码哈希,并删除该成员的全部会话。返回被删除的会话的 token 哈希。成员是游客时返回 ErrMemberNotFound。
SetPassword(ctx context.Context, memberID, passwordHash string) ([][]byte, error)
// 与 SetPassword 相同,但保留发出请求的 keepTokenHash 的会话。这个会话已经不存在时返回 ErrSessionNotFound;密码哈希已经不是
// 校验当前密码时读到的 currentPasswordHash 时返回 ErrAccountPasswordChanged:密码在校验之后被修改或重置过。两种情况都不做任何修改。
ChangePassword(ctx context.Context, memberID, currentPasswordHash, passwordHash string, keepTokenHash []byte) ([][]byte, error)
}
+18 -1
View File
@@ -222,7 +222,7 @@ func (s *Store) DeleteSession(_ context.Context, tokenHash []byte) error {
return nil
}
func (s *Store) SetPassword(_ context.Context, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
func (s *Store) SetPassword(_ context.Context, memberID, passwordHash string) ([][]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
@@ -230,6 +230,23 @@ func (s *Store) SetPassword(_ context.Context, memberID, passwordHash string, ke
return nil, port.ErrMemberNotFound
}
m.passwordHash = passwordHash
return s.deleteSessions(memberID, nil), nil
}
func (s *Store) ChangePassword(_ context.Context, memberID, currentPasswordHash, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || m.username == "" {
return nil, port.ErrMemberNotFound
}
if owner, ok := s.sessions[string(keepTokenHash)]; !ok || owner != memberID {
return nil, port.ErrSessionNotFound
}
if m.passwordHash != currentPasswordHash {
return nil, port.ErrAccountPasswordChanged
}
m.passwordHash = passwordHash
return s.deleteSessions(memberID, keepTokenHash), nil
}
+97
View File
@@ -1,9 +1,11 @@
package bootstrap
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"slices"
"strings"
"testing"
"time"
@@ -155,3 +157,98 @@ func TestRestoredLinksNeedTheirAudio(t *testing.T) {
t.Errorf("音频还在的链接有 %d 个准备任务,期望没有", n)
}
}
// 准备好的链接曲目进了歌单之后,三处封面地址都由对象存储的公开地址和封面的对象键拼出;换一种大小写写法也能找到它。
func TestPlaylistLinkTracks(t *testing.T) {
contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
svc := startService(t)
dbConfig, err := loadDatabaseConfig()
if err != nil {
t.Fatal(err)
}
pool, err := pgxpool.NewWithConfig(t.Context(), dbConfig)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
const link = "https://example.com/Song.mp3"
cover := track.ObjectKey(media.CoverObject(media.Key(link), "jpg"))
prepared := port.PreparedTrack{
Meta: track.Meta{Title: "Song.mp3", CoverKey: cover, PageURL: link, Duration: 2 * time.Second},
Result: track.Prepared{Duration: 2 * time.Second, Gain: 1},
MediaKey: media.Key(link),
MediaSize: 3,
}
if err := postgres.New(pool).SavePreparedTrack(t.Context(), track.Track{Source: track.Link, ID: link}, prepared); err != nil {
t.Fatal(err)
}
coverURL := "https://alisten.example/storage/alisten/" + string(cover)
call := func(request string, status int, resp any) {
t.Helper()
line, got, _, body := send(t, svc.server, request)
if got != status {
t.Fatalf("%s = %d %s,期望 %d", line, got, body, status)
}
if resp != nil {
if err := json.Unmarshal(body, resp); err != nil {
t.Fatalf("%s 的响应体 %s:%v", line, body, err)
}
}
}
var session struct {
MemberToken string `json:"memberToken"`
}
call("POST /api/members\n\n{}", http.StatusCreated, &session)
auth := "\nAuthorization: Bearer " + session.MemberToken
call("POST /api/account"+auth+"\n\n"+`{"username": "alice", "password": "correct horse"}`, http.StatusCreated, nil)
type item struct {
Track struct {
ID string `json:"id"`
} `json:"track"`
Meta struct {
CoverURL string `json:"coverUrl"`
} `json:"meta"`
}
var lists struct {
Items []struct {
ID string `json:"id"`
CoverURL *string `json:"coverUrl"`
} `json:"items"`
}
call("GET /api/me/playlists"+auth, http.StatusOK, &lists)
favorites := lists.Items[0].ID
var added struct {
Item item `json:"item"`
}
call("POST /api/me/playlists/"+favorites+"/items"+auth+"\n\n"+`{"track": {"source": "link", "id": "HTTPS://EXAMPLE.COM/Song.mp3"}}`, http.StatusCreated, &added)
if added.Item.Track.ID != link || added.Item.Meta.CoverURL != coverURL {
t.Errorf("加入的曲目 = %+v,期望 ID %s、封面 %s", added.Item, link, coverURL)
}
call("GET /api/me/playlists"+auth, http.StatusOK, &lists)
if got := lists.Items[0].CoverURL; got == nil || *got != coverURL {
t.Errorf("歌单的封面 = %v,期望 %s", got, coverURL)
}
var items struct {
Items []item `json:"items"`
}
call("GET /api/me/playlists/"+favorites+"/items"+auth, http.StatusOK, &items)
if len(items.Items) != 1 || items.Items[0].Track.ID != link || items.Items[0].Meta.CoverURL != coverURL {
t.Errorf("歌单里的曲目 = %+v,期望一首 ID %s、封面 %s", items.Items, link, coverURL)
}
var memberships struct {
PlaylistIDs []string `json:"playlistIds"`
}
call("POST /api/me/playlist-memberships"+auth+"\n\n"+`{"track": {"source": "link", "id": "https://Example.com/Song.mp3"}}`, http.StatusOK, &memberships)
if !slices.Equal(memberships.PlaylistIDs, []string{favorites}) {
t.Errorf("换一种大小写查所在的歌单 = %v,期望 [%s]", memberships.PlaylistIDs, favorites)
}
call("POST /api/me/playlists/"+favorites+"/removals"+auth+"\n\n"+`{"tracks": [{"source": "link", "id": "hTTps://example.COM/Song.mp3"}]}`, http.StatusNoContent, nil)
call("GET /api/me/playlists/"+favorites+"/items"+auth, http.StatusOK, &items)
if len(items.Items) != 0 {
t.Errorf("换一种大小写移出之后还有 %+v,期望没有", items.Items)
}
}
+27
View File
@@ -318,6 +318,33 @@ Authorization: Bearer $bob
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
PATCH /api/me/playlists/$favorites
Authorization: Bearer $bob
{"name": "别人的名字"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
POST /api/me/playlists/$favorites/items
Authorization: Bearer $bob
{"track": {"source": "netease", "id": "186016"}}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
POST /api/me/playlists/$favorites/removals
Authorization: Bearer $bob
{"tracks": [{"source": "netease", "id": "185811"}]}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
GET /api/me/playlists/$favorites/items
Authorization: Bearer $bob
+3 -12
View File
@@ -474,15 +474,12 @@ func decodeCopyMyPlaylistItemsRequest(raw json.RawMessage, path string) (CopyMyP
if v.From, err = decodeString(m["from"], field(path, "from")); err != nil {
return v, err
}
if v.Tracks, err = decodeArray(decodeTrack)(m["tracks"], field(path, "tracks")); err != nil {
if v.Tracks, err = decodeArray(decodeTrack, 1000)(m["tracks"], field(path, "tracks")); err != nil {
return v, err
}
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
return v, err
}
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 1000); err != nil {
return v, err
}
return v, nil
}
@@ -639,15 +636,12 @@ func decodeMoveMyPlaylistItemsRequest(raw json.RawMessage, path string) (MoveMyP
}
v.Before = &x
}
if v.Tracks, err = decodeArray(decodeTrack)(m["tracks"], field(path, "tracks")); err != nil {
if v.Tracks, err = decodeArray(decodeTrack, 1000)(m["tracks"], field(path, "tracks")); err != nil {
return v, err
}
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
return v, err
}
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 1000); err != nil {
return v, err
}
return v, nil
}
@@ -838,15 +832,12 @@ func decodeRemoveMyPlaylistItemsRequest(raw json.RawMessage, path string) (Remov
if err = requireFields(m, path, "tracks"); err != nil {
return v, err
}
if v.Tracks, err = decodeArray(decodeTrack)(m["tracks"], field(path, "tracks")); err != nil {
if v.Tracks, err = decodeArray(decodeTrack, 1000)(m["tracks"], field(path, "tracks")); err != nil {
return v, err
}
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
return v, err
}
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 1000); err != nil {
return v, err
}
return v, nil
}
+5 -8
View File
@@ -88,12 +88,16 @@ func decodeInteger(raw json.RawMessage, path string) (int64, error) {
return n, nil
}
func decodeArray[T any](decode func(json.RawMessage, string) (T, error)) func(json.RawMessage, string) ([]T, error) {
// 先数元素再逐个解码:过长的数组不必解码就能拒绝。
func decodeArray[T any](decode func(json.RawMessage, string) (T, error), maxItems int) func(json.RawMessage, string) ([]T, error) {
return func(raw json.RawMessage, path string) ([]T, error) {
var elems []json.RawMessage
if bytes.Equal(raw, null) || json.Unmarshal(raw, &elems) != nil {
return nil, invalid(path, "应为数组")
}
if len(elems) > maxItems {
return nil, invalid(path, fmt.Sprintf("元素不能超过 %d 个", maxItems))
}
out := make([]T, len(elems))
for i, e := range elems {
v, err := decode(e, fmt.Sprintf("%s[%d]", path, i))
@@ -113,13 +117,6 @@ func checkMinItems[T any](a []T, path string, n int) error {
return nil
}
func checkMaxItems[T any](a []T, path string, n int) error {
if len(a) > n {
return invalid(path, fmt.Sprintf("元素不能超过 %d 个", n))
}
return nil
}
func checkMinLength(s, path string, n int) error {
if utf8.RuneCountInString(s) < n {
return invalid(path, fmt.Sprintf("长度不能少于 %d 个字符", n))
+5 -3
View File
@@ -87,8 +87,10 @@ func TestDecodeTrackArray(t *testing.T) {
t.Errorf("%s = %q,期望 %q", tt.body, got, tt.want)
}
}
tooMany := `{"tracks":[` + one + strings.Repeat(","+one, 1000) + `]}`
if got := errText(func() error { _, err := DecodeRemoveMyPlaylistItemsRequest([]byte(tooMany)); return err }()); got != "tracks:元素不能超过 1000 个" {
t.Errorf("1001 首 = %q", got)
for _, elem := range []string{one, "{}"} {
tooMany := `{"tracks":[` + elem + strings.Repeat(","+elem, 1000) + `]}`
if got := errText(func() error { _, err := DecodeRemoveMyPlaylistItemsRequest([]byte(tooMany)); return err }()); got != "tracks:元素不能超过 1000 个" {
t.Errorf("1001 个 %s = %q,期望先按个数拒绝、不再解码元素", elem, got)
}
}
}
+1 -1
View File
@@ -391,7 +391,7 @@
{"name": "internal", "code": 1011, "description": "握手时发生内部错误"},
{"name": "serviceRestart", "code": 1012, "description": "服务端实例退出,或实例与 Redis 的订阅连接中断"},
{"name": "unavailable", "code": 1013, "description": "握手时服务暂时不可用"},
{"name": "unauthorized", "code": 4401, "description": "票据无效、用 API Key 握手,或房间设置、修改了密码"},
{"name": "unauthorized", "code": 4401, "description": "票据无效、用 API Key 握手、房间设置或修改了密码,或者票据所属的成员会话已被删除(退出登录、修改或重置账号密码)"},
{"name": "notFound", "code": 4404, "description": "房间不存在,或房间已关闭、被删除"},
{"name": "handshakeTimeout", "code": 4408, "description": "握手超时"},
{"name": "clientOutdated", "code": 4426, "description": "前端与服务端的协议版本不一致"},
+1 -1
View File
@@ -1741,7 +1741,7 @@
},
"info": {
"title": "Alisten",
"version": "12f9c11ba788ac55"
"version": "5b9cd2cb39780e46"
},
"openapi": "3.1.0",
"paths": {
+1 -1
View File
@@ -4,7 +4,7 @@ package protocol
import "encoding/json"
const Version = "12f9c11ba788ac55"
const Version = "5b9cd2cb39780e46"
type APIKey struct {
Key string `json:"key"`
+2 -1
View File
@@ -5,7 +5,7 @@ import { chatStore } from '@/features/chat'
import { historyStore } from '@/features/history'
import { lyricsStore } from '@/features/lyrics'
import { playerStore, reminderStore, volumeStore } from '@/features/playback'
import { playlistsStore } from '@/features/playlists'
import { playlistsStore, setAccountSettingsOpener } from '@/features/playlists'
import { roomEntryStore } from '@/features/rooms'
import { searchStore } from '@/features/search'
import { appearanceStore, performanceStore, profileStore } from '@/features/settings'
@@ -28,6 +28,7 @@ volumeStore.start()
brandStore.start()
profileStore.start()
playlistsStore.start()
setAccountSettingsOpener(() => navigationStore.open('settings'))
playerStore.start()
reminderStore.start()
historyStore.start()
+83
View File
@@ -222,6 +222,7 @@ describe('token 失效时更换身份', () => {
it('其他标签页已经换了 token 时不删除新的 token', async () => {
local.setItem(memberTokenKey, 'expired')
local.setItem('alisten.ticket.r1', 't1')
routes.set('PATCH /api/me', () => {
local.setItem(memberTokenKey, 'good')
return unauthorized()
@@ -230,6 +231,7 @@ describe('token 失效时更换身份', () => {
await expect(api.updateMe({ name: 'a' })).rejects.toThrow('刚才的操作没有生效')
expect(local.getItem(memberTokenKey)).toBe('good')
expect(local.getItem('alisten.ticket.r1')).toBe('t1')
expect(callsTo('POST /api/members')).toHaveLength(0)
expect(events).toEqual([{ kind: 'rejected' }, { kind: 'renewed', member }])
})
@@ -290,6 +292,19 @@ describe('token 失效时更换身份', () => {
expect(local.getItem(memberTokenKey)).toBe('good')
})
it('修改密码时当前密码错误返回 INVALID_CREDENTIALS,不更换身份', async () => {
local.setItem(memberTokenKey, 'good')
local.setItem('alisten.ticket.r1', 't1')
routes.set('PUT /api/account/password', () => errorResponse(401, 'INVALID_CREDENTIALS', '当前密码错误'))
const { api, events, IdentityRenewedError } = await load()
const error: unknown = await api.changePassword({ currentPassword: 'wrong horse', newPassword: 'correct horse' }).catch((reason: unknown) => reason)
expect(error).not.toBeInstanceOf(IdentityRenewedError)
expect(error).toMatchObject({ code: 'INVALID_CREDENTIALS', status: 401 })
expect(events).toEqual([])
expect(local.getItem(memberTokenKey)).toBe('good')
expect(local.getItem('alisten.ticket.r1')).toBe('t1')
})
it('账号密码错误不更换身份', async () => {
local.setItem(memberTokenKey, 'good')
routes.set('POST /api/sessions', () => errorResponse(401, 'INVALID_CREDENTIALS', '用户名或密码错误'))
@@ -300,6 +315,74 @@ describe('token 失效时更换身份', () => {
})
})
describe('其他标签页更换身份', () => {
let listeners: ((event: { key: string | null }) => void)[]
let changed: ReturnType<typeof vi.fn<() => void>>
function storageEvent(key: string | null = memberTokenKey) {
for (const listener of listeners)
listener({ key })
}
beforeEach(() => {
listeners = []
changed = vi.fn<() => void>()
vi.stubGlobal('window', {
addEventListener: (type: string, listener: (event: { key: string | null }) => void) => {
if (type === 'storage')
listeners.push(listener)
},
})
})
it('本标签页正在使用的 token 被换成另一个时通知切换', async () => {
local.setItem(memberTokenKey, 'good')
const { api, followMemberTokenChanges } = await load()
followMemberTokenChanges(changed)
await api.getMe()
local.setItem(memberTokenKey, 'other')
storageEvent()
expect(changed).toHaveBeenCalledOnce()
})
it('token 没有变化、无关的 key 或被删除时不切换', async () => {
local.setItem(memberTokenKey, 'good')
const { api, followMemberTokenChanges } = await load()
followMemberTokenChanges(changed)
await api.getMe()
storageEvent()
local.setItem('alisten.volume', '80')
storageEvent('alisten.volume')
local.removeItem(memberTokenKey)
storageEvent()
expect(changed).not.toHaveBeenCalled()
})
it('两个标签页都没有 token 各自创建游客时,先创建的另一个标签页不触发切换', async () => {
const { api, followMemberTokenChanges } = await load()
followMemberTokenChanges(changed)
const pending = api.getMe()
local.setItem(memberTokenKey, 'other-tab')
storageEvent()
expect(changed).not.toHaveBeenCalled()
await pending
})
it('本标签页后写入的 token 覆盖了另一个标签页,迟到的事件不触发切换', async () => {
const { api, followMemberTokenChanges } = await load()
followMemberTokenChanges(changed)
const pending = api.getMe()
local.setItem(memberTokenKey, 'other-tab')
await pending
expect(local.getItem(memberTokenKey)).toBe('issued')
storageEvent()
expect(changed).not.toHaveBeenCalled()
})
})
describe('换取票据', () => {
const route = 'POST /api/rooms/r1/tickets'
+18 -2
View File
@@ -1,7 +1,7 @@
import type { Auth, CreateTicketRequest, CreateTicketResponse, ErrorResponse, HttpRequest } from './http.gen'
import type { ErrorCode, Member } from '@/core/realtime'
import { Emitter } from '@/shared/lib/emitter'
import { deleteAllTickets, deleteMemberToken, loadAdminToken, loadMemberToken, saveMemberToken } from './credentials'
import { deleteAllTickets, deleteMemberToken, loadAdminToken, loadMemberToken, memberTokenKey, saveMemberToken } from './credentials'
import { httpClient } from './http.gen'
export class ApiError extends Error {
@@ -134,13 +134,17 @@ export const api = {
}
let creatingMember: Promise<string> | null = null
let usedMemberToken: string | null = null
function memberToken(): Promise<string> {
const stored = loadMemberToken()
if (stored)
if (stored) {
usedMemberToken = stored
return Promise.resolve(stored)
}
creatingMember ??= api.createMember().then(({ memberToken }) => {
saveMemberToken(memberToken)
usedMemberToken = memberToken
return memberToken
}).finally(() => {
creatingMember = null
@@ -175,3 +179,15 @@ async function replaceIdentity(rejected: string): Promise<void> {
}
identityEvents.emit({ kind: 'renewed', member })
}
// 只有本标签页已经用过 token、并且保存的 token 换成了另一个时才算身份变了。
// 两个标签页同时创建游客时,对方先写入的 token 会被后写入的覆盖,此时本标签页还没有用过 token,或用的就是最终保存的 token,都不触发,避免互相刷新。
export function followMemberTokenChanges(onChange: () => void): void {
window.addEventListener('storage', (event) => {
if (event.key !== memberTokenKey)
return
const stored = loadMemberToken()
if (usedMemberToken !== null && stored !== null && stored !== usedMemberToken)
onChange()
})
}
+1 -1
View File
@@ -1,4 +1,4 @@
const memberTokenKey = 'alisten.member-token'
export const memberTokenKey = 'alisten.member-token'
const adminTokenKey = 'alisten.admin-token'
const ticketKeyPrefix = 'alisten.ticket.'
+1 -1
View File
@@ -1,4 +1,4 @@
export { api, ApiError, identityEvents, IdentityRenewedError } from './client'
export { api, ApiError, followMemberTokenChanges, identityEvents, IdentityRenewedError } from './client'
export type { IdentityEvent } from './client'
export { deleteAdminToken, deleteAllTickets, deleteMemberToken, deleteTicket, loadAdminToken, loadTicket, saveAdminToken, saveMemberToken, saveTicket } from './credentials'
export type * from './http.gen'
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = '12f9c11ba788ac55'
export const PROTOCOL_VERSION = '5b9cd2cb39780e46'
export interface APIKey {
key: string
+1 -1
View File
@@ -43,7 +43,7 @@
.playback-progress { position: absolute; left: 0; right: 0; bottom: 0; margin-top: 0; }
.player-center { flex-shrink: 0; }
.player-track { position: static; }
.player-time { top: 30px; right: 54px; }
.player-time { top: 30px; right: 90px; }
.progress-track { height: 2px; border-radius: 0 0 8px 8px; }
.player-tools { display: none; }
}
+16 -4
View File
@@ -1,7 +1,8 @@
import { ListMusic, MessageCircle, SkipForward, Volume2, VolumeX } from 'lucide-react'
import { useEffect, useRef } from 'react'
import type { Track } from '@/core/realtime'
import { ListMusic, ListPlus, MessageCircle, SkipForward, Volume2, VolumeX } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { roomStore } from '@/core/room'
import { SaveToPlaylistButton } from '@/features/playlists'
import { SaveToPlaylistDialog, saveToPlaylistLabel } from '@/features/playlists'
import { sourceName } from '@/features/queue'
import { formatDuration } from '@/shared/lib/time'
import { useStore } from '@/shared/lib/use-store'
@@ -70,6 +71,8 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
const artist = meta?.artist || '未知艺术家'
const source = nowPlaying ? sourceName(nowPlaying.entry.track) : undefined
const note = nowPlaying?.entry.by ? `${nowPlaying.entry.by.name} 点的歌` : undefined
// 记下打开时的歌:对话框开着时换了歌,或者这首播完了,勾选仍然作用于打开时的那首。
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
return (
<section className="player-bar" aria-label="播放器">
<button type="button" className="player-artwork" onClick={onLyrics} aria-label="查看歌词">
@@ -88,10 +91,18 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
</div>
<div className="player-right">
<div className="player-center">
<IconButton
icon={ListPlus}
label={meta ? saveToPlaylistLabel(meta.title) : '保存到歌单'}
disabled={!nowPlaying}
onClick={() => {
if (nowPlaying)
setSaving({ track: nowPlaying.entry.track, title: nowPlaying.entry.meta.title })
}}
/>
<AsyncIconButton icon={SkipForward} label="投票切歌" disabled={!nowPlaying || !connected} onAction={onSkip} />
</div>
<div className="player-tools">
{nowPlaying && <SaveToPlaylistButton track={nowPlaying.entry.track} title={nowPlaying.entry.meta.title} />}
<IconButton icon={muted ? VolumeX : Volume2} label={muted ? '取消静音' : '静音'} onClick={() => volumeStore.setMuted(!muted)} />
<div className="volume-control">
<input
@@ -114,6 +125,7 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
<IconButton icon={ListMusic} label="播放列表" className="player-panel-toggle" disabled={!canOpenPanels} aria-pressed={queueOpen} onClick={onQueue} />
</div>
</div>
{saving && <SaveToPlaylistDialog track={saving.track} title={saving.title} onClose={() => setSaving(null)} />}
</section>
)
}
@@ -0,0 +1,10 @@
// 游客在保存对话框里点“去账号设置”时打开账号设置。设置页在哪里、怎样打开由应用壳决定,启动时设置。
let open = (): void => {}
export function openAccountSettings(): void {
open()
}
export function setAccountSettingsOpener(opener: () => void): void {
open = opener
}
+1
View File
@@ -1,5 +1,6 @@
import { lazyPage } from '@/shared/ui'
export { setAccountSettingsOpener } from './account-settings'
export { playlistsStore } from './playlists-store'
export { saveToPlaylistLabel } from './save-label'
export { SaveToPlaylistButton, SaveToPlaylistDialog } from './save-to-playlist'
+41 -31
View File
@@ -2,7 +2,7 @@ import type { MovePlan } from './track-order'
import type { MyPlaylist, MyPlaylistItem, Track } from '@/core/realtime'
import type { SongAction } from '@/shared/ui'
import { ArrowLeft, CirclePlus, ListMusic, ListPlus, Trash2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { roomStore } from '@/core/room'
@@ -18,6 +18,7 @@ import { playlistsStore, trackKey } from './playlists-store'
import { saveToPlaylistLabel } from './save-label'
import { SaveToPlaylistDialog } from './save-to-playlist'
import { SelectionBar } from './selection-bar'
import { TrackEdits } from './track-edits'
import { dropAt } from './track-order'
import { noSelection, retain, selectAll } from './track-selection'
import './playlists.css'
@@ -28,6 +29,7 @@ function keysOf(items: readonly MyPlaylistItem[]): string[] {
export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void }) {
const playlist = useStore(playlistsStore, s => s.playlists?.find(p => p.id === id) ?? null)
const listed = useStore(playlistsStore, s => s.playlists !== null)
const connected = useStore(roomStore, s => s.status === 'open')
const ephemeral = useStore(roomStore, s => s.room?.room.kind === 'ephemeral')
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
@@ -40,23 +42,25 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
const [savingSelected, setSavingSelected] = useState(false)
const [confirmingRemoval, setConfirmingRemoval] = useState(false)
// 移动按发起的先后逐个请求:每次的 before 都基于上一次移动之后的顺序。一次失败之后,排在它后面的不再发出。
const movesRef = useRef({ tail: Promise.resolve(), generation: 0 })
const [edits] = useState(() => new TrackEdits(() => setAttempt(n => n + 1)))
// 歌单的更新时间变了就重新读取:加入、移出、移动和复制都会推进它,歌曲数不变时顺序也可能变了。
useEffect(() => {
let active = true
const usable = edits.read()
api.listMyPlaylistItems(id).then(({ items }) => {
if (!active)
if (!active || !usable())
return
setItems(items)
setFailed(null)
setSelection(selection => retain(selection, keysOf(items)))
}, (error: unknown) => {
if (active)
if (active && usable())
setFailed(errorMessage(error))
})
return () => {
active = false
}
}, [id, attempt, playlist?.trackCount])
}, [id, attempt, playlist?.updatedAtMs, edits])
useEffect(() => {
const refresh = () => setAttempt(n => n + 1)
window.addEventListener('focus', refresh)
@@ -78,28 +82,23 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
}, [selecting])
const [now] = useState(Date.now)
const name = playlist?.name ?? '歌单'
// 读到了自己的歌单,里面却没有它:在别处删掉了,或者换了账号。
const gone = listed && !playlist
// 读不到时不再显示之前的曲目:它们可能已经不在了,也可能属于之前的账号。
const shown = gone || failed ? null : items
const exitSelection = () => {
setSelection(noSelection)
setPicking(false)
}
// 先在本地排好再请求;失败时回到原来的顺序,并重新读取:歌单可能在别处变过。
const move = (target: MyPlaylist, current: MyPlaylistItem[], plan: MovePlan<string>) => {
const byKey = new Map(current.map(item => [trackKey(item.track), item]))
const trackOf = (key: string) => byKey.get(key)!.track
const tracks = plan.moved.map(trackOf)
const before = plan.before === undefined ? undefined : trackOf(plan.before)
setItems(plan.order.map(key => byKey.get(key)!))
const queue = movesRef.current
const generation = queue.generation
queue.tail = queue.tail.then(async () => {
if (queue.generation !== generation || await playlistsStore.moveItems(target, tracks, before))
return
queue.generation++
setItems(current)
setAttempt(n => n + 1)
})
void edits.run(() => playlistsStore.moveItems(target, tracks, before), () => setItems(current))
}
const remove = async (target: MyPlaylist, current: MyPlaylistItem[], tracks: Track[]) => {
@@ -108,14 +107,23 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
const previousSelection = selection
setItems(rest)
setSelection(retain(selection, keysOf(rest)))
if (await playlistsStore.removeItems(target, tracks))
return
setItems(current)
setSelection(previousSelection)
await edits.run(() => playlistsStore.removeItems(target, tracks), () => {
setItems(current)
setSelection(previousSelection)
})
}
let body
if (failed && items === null) {
if (gone) {
body = (
<EmptyState
icon={ListMusic}
title="这个歌单不存在了"
action={<button type="button" className="button-secondary" onClick={onBack}>返回我的歌单</button>}
/>
)
}
else if (failed) {
body = (
<EmptyState
icon={ListMusic}
@@ -212,7 +220,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
</p>
)}
<div className="my-playlist-actions">
{items && items.length > 0 && (
{shown && shown.length > 0 && (
<button type="button" className="button-secondary my-playlist-select" onClick={() => selecting ? exitSelection() : setPicking(true)}>
{selecting ? '完成' : '选择'}
</button>
@@ -222,35 +230,37 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
</div>
</header>
{body}
{!connected && items?.length ? <p className="text-ui-caption muted my-playlist-hint">进入房间之后才能点歌。</p> : null}
{playlist && items && selected.length > 0 && (
{/* 选中数的播报常驻在页面上:选择栏要等选中第一首才出现,跟着它出现的播报区域读屏软件不会播报。 */}
<p className="sr-only" role="status">{shown && selected.length > 0 ? `已选 ${selected.length} 首` : ''}</p>
{!connected && shown?.length ? <p className="text-ui-caption muted my-playlist-hint">进入房间之后才能点歌。</p> : null}
{playlist && shown && selected.length > 0 && (
<SelectionBar
count={selected.length}
allSelected={selected.length === items.length}
allSelected={selected.length === shown.length}
onSave={() => setSavingSelected(true)}
onTop={() => {
const plan = dropAt(keys, selection.keys, 0)
if (plan)
move(playlist, items, plan)
move(playlist, shown, plan)
}}
onBottom={() => {
const plan = dropAt(keys, selection.keys, keys.length)
if (plan)
move(playlist, items, plan)
move(playlist, shown, plan)
}}
onRemove={() => {
if (selected.length > 1)
setConfirmingRemoval(true)
else
void remove(playlist, items, selected)
void remove(playlist, shown, selected)
}}
onToggleAll={() => setSelection(selected.length === items.length ? noSelection : selectAll(keys))}
onToggleAll={() => setSelection(selected.length === shown.length ? noSelection : selectAll(keys))}
onClose={exitSelection}
/>
)}
{saving && <SaveToPlaylistDialog track={saving.track} title={saving.title} onClose={() => setSaving(null)} />}
{playlist && savingSelected && <SaveToPlaylistDialog from={playlist} tracks={selected} onClose={() => setSavingSelected(false)} />}
{playlist && items && confirmingRemoval && (
{playlist && shown && confirmingRemoval && (
<Modal title="移出歌曲" onClose={() => setConfirmingRemoval(false)}>
<p className="text-ui-base muted">{`把 ${selected.length} 首歌移出这个歌单?`}</p>
<div className="dialog-actions">
@@ -260,7 +270,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
className="button-destructive"
onClick={() => {
setConfirmingRemoval(false)
void remove(playlist, items, selected)
void remove(playlist, shown, selected)
}}
>
移出
+38 -12
View File
@@ -16,6 +16,17 @@ import { moveByOne } from './track-order'
import { selectAll, selectRange, toggle } from './track-selection'
import { useTrackDrag } from './use-track-drag'
function rowOf(list: HTMLElement | null, key: string): Element | null {
return list?.querySelector(`[data-key="${CSS.escape(key)}"]`) ?? null
}
// 多选时焦点落在勾选框上,读屏软件才读得出这一行是否选中;平时落在行上。
function focusRow(row: Element | null | undefined, selecting: boolean): void {
const target = selecting ? row?.querySelector('.playlist-track-check') : row
if (target instanceof HTMLElement)
target.focus()
}
export function PlaylistTracks({ items, playing, selection, selecting, actions, onSelect, onMove }: {
items: MyPlaylistItem[]
playing: Track | undefined
@@ -29,6 +40,8 @@ export function PlaylistTracks({ items, playing, selection, selecting, actions,
const lineRef = useRef<HTMLDivElement>(null)
const labelRef = useRef<HTMLDivElement>(null)
const focusAfterMoveRef = useRef<string | null>(null)
// 最后获得焦点的行。勾选框随多选出现和消失,焦点在它上面时会丢。
const focusedRef = useRef<string | null>(null)
const keys = items.map(item => trackKey(item.track))
const { dragging, onPointerDown } = useTrackDrag({
listRef,
@@ -48,8 +61,17 @@ export function PlaylistTracks({ items, playing, selection, selecting, actions,
if (key === null)
return
focusAfterMoveRef.current = null
listRef.current?.querySelector<HTMLElement>(`[data-key="${CSS.escape(key)}"]`)?.focus()
}, [items])
focusRow(rowOf(listRef.current, key), selecting)
}, [items, selecting])
// 进出多选时,焦点留在原来那一行,落到这一行在新模式下的位置。
useLayoutEffect(() => {
const key = focusedRef.current
const active = document.activeElement
if (key === null || (active !== null && active !== document.body && !active.matches('.playlist-track')))
return
focusRow(rowOf(listRef.current, key), selecting)
}, [selecting])
const onRowClick = (event: MouseEvent, key: string) => {
if ((event.target as Element).closest('button, a'))
@@ -66,12 +88,13 @@ export function PlaylistTracks({ items, playing, selection, selecting, actions,
onSelect(selectAll(keys))
return
}
// 其余按键只在焦点落在行本身时处理,行里的按钮保持原来的行为。
const row = event.target as HTMLElement
const key = row.dataset.key
if (key === undefined)
// 其余按键只在焦点落在行本身或它的勾选框上时处理,行里的按钮保持原来的行为。勾选框上的空格由浏览器切换。
const target = event.target as HTMLElement
const row = target.closest<HTMLElement>('[data-key]')
const key = row?.dataset.key
if (!row || key === undefined || (target !== row && !target.matches('.playlist-track-check')))
return
if (event.key === ' ' && selecting) {
if (event.key === ' ' && selecting && target === row) {
event.preventDefault()
onSelect(toggle(selection, key))
return
@@ -81,9 +104,7 @@ export function PlaylistTracks({ items, playing, selection, selecting, actions,
event.preventDefault()
const delta = event.key === 'ArrowUp' ? -1 : 1
if (!event.altKey) {
const next = delta < 0 ? row.previousElementSibling : row.nextElementSibling
if (next instanceof HTMLElement)
next.focus()
focusRow(delta < 0 ? row.previousElementSibling : row.nextElementSibling, selecting)
return
}
const plan = moveByOne(keys, selection.keys.has(key) ? selection.keys : new Set([key]), delta)
@@ -113,15 +134,20 @@ export function PlaylistTracks({ items, playing, selection, selecting, actions,
key={key}
data-key={key}
className="song-row playlist-track"
tabIndex={0}
tabIndex={selecting ? -1 : 0}
data-playing={isPlaying || undefined}
data-selected={selected || undefined}
data-dragging={dragging?.has(key) || undefined}
onClick={event => onRowClick(event, key)}
onFocus={() => {
focusedRef.current = key
}}
onPointerDown={event => onPointerDown(event, key)}
// 按住封面拖动时,浏览器会开始原生的图片拖拽并取消指针事件,排序的拖动随之中断。
onDragStart={event => event.preventDefault()}
>
{selecting
? <input type="checkbox" className="playlist-track-check" tabIndex={-1} checked={selected} readOnly aria-label={`选择${bookTitle(meta.title)}`} />
? <input type="checkbox" className="playlist-track-check" checked={selected} readOnly aria-label={`选择${bookTitle(meta.title)}`} />
: <span className="song-index text-ui-sm muted">{String(index + 1).padStart(2, '0')}</span>}
{/* 多选时点封面也是切换选中,不打开音源页面。 */}
<SongArtwork coverUrl={meta.coverUrl} title={meta.title} href={selecting ? undefined : meta.pageUrl} sourceName={source} />
@@ -42,6 +42,7 @@ let failCopy: boolean
let failMove: boolean
let bodies: unknown[]
let holdReads: boolean
let failReads: boolean
let serverPlaylists: typeof favorites[]
let server: Map<string, Map<string, Track>>
@@ -66,6 +67,8 @@ function items(playlist: string): Map<string, Track> {
async function read(body: unknown): Promise<Response> {
if (holdReads)
await new Promise<void>(resolve => release.push(resolve))
if (failReads)
return json(503, { error: { code: 'UNAVAILABLE', message: '暂时不可用' } })
return json(200, body)
}
@@ -79,6 +82,7 @@ beforeEach(() => {
failMove = false
bodies = []
holdReads = false
failReads = false
serverPlaylists = [favorites]
server = new Map([['p1', new Map([[key(sunny), sunny]])]])
vi.stubGlobal('window', new EventTarget())
@@ -205,37 +209,69 @@ describe('playlistsStore', () => {
expect(included(rain)).toBe(true)
})
// 同步结束后会重新读取,读到的服务端状态也能把勾选改对。下面几个用例挡住这次读取,只看回滚本身。
it('第一次请求失败时以服务端为准,提示服务端的文案', async () => {
const { playlistsStore, included } = await loaded()
const { notificationStore } = await import('@/shared/ui')
failAdd = true
void playlistsStore.setMembership(favorites, rain, true)
holdReads = true
const done = playlistsStore.setMembership(favorites, rain, true)
void playlistsStore.setMembership(favorites, rain, false)
void playlistsStore.setMembership(favorites, rain, true)
await settle()
await flush()
release.shift()!()
await done
expect(writes()).toEqual(['POST /api/me/playlists/p1/items'])
expect(included(rain)).toBe(false)
expect(notificationStore.state.notifications.map(n => n.message)).toEqual([notReady])
holdReads = false
await settle()
expect(included(rain)).toBe(false)
})
it('移出失败时恢复勾选', async () => {
const { playlistsStore, included } = await loaded()
failRemove = true
holdReads = true
const done = playlistsStore.setMembership(favorites, sunny, false)
expect(included(sunny)).toBe(false)
await settle()
await flush()
release.shift()!()
await done
expect(included(sunny)).toBe(true)
holdReads = false
await settle()
expect(included(sunny)).toBe(true)
})
it('加入成功后移出失败,勾选恢复为已加入', async () => {
const { playlistsStore, included } = await loaded()
failRemove = true
void playlistsStore.setMembership(favorites, rain, true)
holdReads = true
const done = playlistsStore.setMembership(favorites, rain, true)
void playlistsStore.setMembership(favorites, rain, false)
await settle()
await flush()
release.shift()!()
await flush()
release.shift()!()
await done
expect(writes()).toEqual(['POST /api/me/playlists/p1/items', 'POST /api/me/playlists/p1/removals'])
expect(included(rain)).toBe(true)
holdReads = false
await settle()
expect(included(rain)).toBe(true)
})
it('移出失败后重新读取也失败,勾选保持回滚的结果', async () => {
const { playlistsStore, included } = await loaded()
failRemove = true
failReads = true
const done = playlistsStore.setMembership(favorites, sunny, false)
await settle()
await done
expect(requests).toEqual(['POST /api/me/playlists/p1/removals', 'POST /api/me/playlist-memberships', 'GET /api/me/playlists'])
expect(included(sunny)).toBe(true)
expect(playlistsStore.state.failed).toBe(true)
})
it('读取期间正在加入的歌以本地为准', async () => {
@@ -346,6 +382,30 @@ describe('playlistsStore', () => {
expect(notificationStore.state.notifications.map(n => n.message)).toEqual([changed])
})
// 详情页的用法:移动经过 TrackEdits 排队。CONFLICT 说明歌单在别处变了,排在后面的移动基于过时的顺序。
it('排队的移动遇到 CONFLICT:提示服务端的文案,回到这次移动之前,后面的不再发出,结束后重新读取', async () => {
const { playlistsStore } = await loaded()
const { notificationStore } = await import('@/shared/ui')
const { TrackEdits } = await import('./track-edits')
let rereads = 0
const edits = new TrackEdits(() => rereads++)
failMove = true
let shown = '第一次移动之后'
void edits.run(() => playlistsStore.moveItems(favorites, [rain], sunny), () => {
shown = '移动之前'
})
shown = '第二次移动之后'
const done = edits.run(() => playlistsStore.moveItems(favorites, [sunny]), () => {
shown = '第一次移动之后'
})
await settle()
await done
expect(writes()).toEqual(['POST /api/me/playlists/p1/moves'])
expect(shown).toBe('移动之前')
expect(rereads).toBe(1)
expect(notificationStore.state.notifications.map(n => n.message)).toEqual([changed])
})
it('游客不读取歌单', async () => {
const { playlistsStore } = await import('./playlists-store')
playlistsStore.start()
+2 -2
View File
@@ -114,7 +114,7 @@
.playlist-tracks { position: relative; }
.playlist-track { user-select: none; -webkit-user-select: none; }
.playlist-track:focus-visible { outline: 2px solid var(--color-brand); outline-offset: -2px; }
.playlist-track:is(:focus-visible, :has(> .playlist-track-check:focus-visible)) { outline: 2px solid var(--color-brand); outline-offset: -2px; }
.playlist-track[data-selected], .playlist-track[data-selected]:hover { background: var(--color-selected); }
.playlist-track[data-dragging] { opacity: .4; }
.playlist-track-check { margin: 0 3px; }
@@ -123,7 +123,7 @@
.playlist-tracks[data-dragging] .playlist-track:not([data-selected]):hover { background: transparent; }
@media (hover: hover) {
.playlist-track-handle { opacity: 0; transition: opacity 120ms ease; }
.playlist-tracks:not([data-dragging]) .playlist-track:is(:hover, :focus-visible) .playlist-track-handle { opacity: 1; }
.playlist-tracks:not([data-dragging]) .playlist-track:is(:hover, :focus-visible, :has(> .playlist-track-check:focus-visible)) .playlist-track-handle { opacity: 1; }
}
.playlist-drop-line { position: absolute; top: 0; left: 8px; right: 8px; height: 2px; margin-top: -1px; border-radius: 1px; background: var(--color-brand); pointer-events: none; visibility: hidden; }
.playlist-drag-label { position: fixed; top: 0; left: 0; z-index: 40; max-width: 240px; padding: 4px 10px; overflow: hidden; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-popover); color: var(--color-foreground); box-shadow: 0 8px 24px var(--color-shadow); text-overflow: ellipsis; white-space: nowrap; pointer-events: none; visibility: hidden; }
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from 'react'
import { profileStore } from '@/features/settings'
import { useStore } from '@/shared/lib/use-store'
import { IconButton, Modal, notificationStore, Skeleton } from '@/shared/ui'
import { openAccountSettings } from './account-settings'
import { CreatePlaylistForm } from './create-playlist-form'
import { FavoritesMark, PlaylistCover } from './playlist-cover'
import { sortPlaylists } from './playlist-sort'
@@ -103,7 +104,23 @@ export function SaveToPlaylistDialog(props: SaveToPlaylistProps) {
let body
if (!account) {
body = <p className="text-ui-base muted">注册账号之后就能建自己的歌单。</p>
body = (
<>
<p className="text-ui-base muted">注册或登录之后就能把歌保存到歌单。</p>
<div className="dialog-actions">
<button
type="button"
className="button-primary"
onClick={() => {
onClose()
openAccountSettings()
}}
>
去账号设置
</button>
</div>
</>
)
}
else if (readFailed || (playlists === null && failed)) {
body = (
+1 -1
View File
@@ -24,7 +24,7 @@ export function SelectionBar({ count, allSelected, onSave, onTop, onBottom, onRe
}) {
return (
<div className="playlist-selection-bar" role="toolbar" aria-label="选中的歌">
<span className="playlist-selection-count text-ui-sm" aria-live="polite">{`已选 ${count} 首`}</span>
<span className="playlist-selection-count text-ui-sm">{`已选 ${count} 首`}</span>
<BarButton icon={ListPlus} text="保存到歌单" label="把选中的歌保存到歌单" onClick={onSave} />
<BarButton icon={ArrowUpToLine} text="移到最前" label="把选中的歌移到最前" onClick={onTop} />
<BarButton icon={ArrowDownToLine} text="移到最后" label="把选中的歌移到最后" onClick={onBottom} />
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest'
import { TrackEdits } from './track-edits'
// 由测试决定每个请求何时完成、成功与否。
function requests() {
const pending: ((ok: boolean) => void)[] = []
const sent: string[] = []
const request = (name: string) => () => {
sent.push(name)
return new Promise<boolean>(resolve => pending.push(resolve))
}
const settle = async (ok: boolean) => {
await flush()
pending.shift()!(ok)
await flush()
}
return { request, sent, settle }
}
function flush() {
return new Promise(resolve => setTimeout(resolve, 0))
}
function setup() {
let rereads = 0
const edits = new TrackEdits(() => rereads++)
return { edits, rereads: () => rereads }
}
describe('trackEdits', () => {
it('没有修改时读到的结果可以用', () => {
const { edits } = setup()
const usable = edits.read()
expect(usable()).toBe(true)
})
it('修改进行中返回的读取不能用', async () => {
const { edits } = setup()
const { request, settle } = requests()
void edits.run(request('移出'), () => {})
const usable = edits.read()
expect(usable()).toBe(false)
await settle(true)
expect(usable()).toBe(false)
})
it('读取开始之后发起的修改,即使在读取返回前已经完成,读到的也不能用', async () => {
const { edits } = setup()
const { request, settle } = requests()
const usable = edits.read()
void edits.run(request('移动'), () => {})
await settle(true)
expect(usable()).toBe(false)
})
it('最后一个修改完成之后重新读取一次', async () => {
const { edits, rereads } = setup()
const { request, settle } = requests()
void edits.run(request('移出'), () => {})
void edits.run(request('移动'), () => {})
await settle(true)
expect(rereads()).toBe(0)
await settle(true)
expect(rereads()).toBe(1)
expect(edits.read()()).toBe(true)
})
it('修改按发起的先后逐个请求,前一个完成之后才发下一个', async () => {
const { edits } = setup()
const { request, sent, settle } = requests()
void edits.run(request('移到最前'), () => {})
void edits.run(request('移到最后'), () => {})
await flush()
expect(sent).toEqual(['移到最前'])
await settle(true)
expect(sent).toEqual(['移到最前', '移到最后'])
})
it('一次失败之后,排在后面的不再发出,列表回到失败的那次之前,结束后重新读取一次', async () => {
const { edits, rereads } = setup()
const { request, sent, settle } = requests()
const rolledBack: string[] = []
let shown = '第一次移动之后'
void edits.run(request('第一次移动'), () => {
rolledBack.push('第一次移动')
shown = '移动之前'
})
shown = '第二次移动之后'
const done = edits.run(request('第二次移动'), () => {
rolledBack.push('第二次移动')
shown = '第一次移动之后'
})
await settle(false)
await done
expect(sent).toEqual(['第一次移动'])
expect(rolledBack).toEqual(['第一次移动'])
expect(shown).toBe('移动之前')
expect(rereads()).toBe(1)
})
it('失败之后新发起的修改照常请求', async () => {
const { edits } = setup()
const { request, sent, settle } = requests()
void edits.run(request('移动'), () => {})
await settle(false)
void edits.run(request('移出'), () => {})
await flush()
expect(sent).toEqual(['移动', '移出'])
})
it('移出失败时回到移出之前,全部完成之后重新读取', async () => {
const { edits, rereads } = setup()
const { request, settle } = requests()
let items = ['晴天', '七里香']
const before = items
items = ['七里香']
const done = edits.run(request('移出'), () => {
items = before
})
await settle(false)
await done
expect(items).toEqual(['晴天', '七里香'])
expect(rereads()).toBe(1)
})
})
+39
View File
@@ -0,0 +1,39 @@
// 详情页对曲目的修改(移动、移出)先在本地改好再请求。请求按发起的先后逐个发出,每次都基于上一次修改之后的顺序;
// 一次失败之后,排在它后面的不再发出,由失败的那次回到它之前的样子。
// 修改进行中读到的列表可能是修改之前的,不能用;最后一个修改结束之后再读一次,以服务端为准。
export class TrackEdits {
readonly #reread: () => void
#tail: Promise<void> = Promise.resolve()
#generation = 0
#pending = 0
#started = 0
constructor(reread: () => void) {
this.#reread = reread
}
// request 失败时返回 false,不抛出。
run(request: () => Promise<boolean>, rollback: () => void): Promise<void> {
const generation = this.#generation
this.#pending++
this.#started++
const done = this.#tail.then(async () => {
if (this.#generation !== generation || await request())
return
this.#generation++
rollback()
}).finally(() => {
if (--this.#pending === 0)
this.#reread()
})
this.#tail = done
return done
}
// 读取开始时调用,返回的函数在读取返回时判断读到的列表能不能用。
read(): () => boolean {
const idle = this.#pending === 0
const started = this.#started
return () => idle && this.#started === started
}
}
+6 -4
View File
@@ -6,7 +6,7 @@ import { roomStore } from '@/core/room'
import { SaveToPlaylistDialog, saveToPlaylistLabel } from '@/features/playlists'
import { bookTitle } from '@/shared/lib/book-title'
import { useStore } from '@/shared/lib/use-store'
import { EmptyState, PlayingBars, Select, SongArtwork, SongList } from '@/shared/ui'
import { EmptyState, IconButton, PlayingBars, Select, SongArtwork, SongList } from '@/shared/ui'
import { likeEntry, removeEntry, setPlayMode } from './actions'
import { sourceName } from './source'
import './queue.css'
@@ -28,7 +28,7 @@ function songItem(entry: Entry): SongItem {
}
}
function QueueCard({ label, entry, className = '', playing = false }: { label: string, entry: Entry, className?: string, playing?: boolean }) {
function QueueCard({ label, entry, className = '', playing = false, onSave }: { label: string, entry: Entry, className?: string, playing?: boolean, onSave: () => void }) {
const song = songItem(entry)
return (
<div className={`queue-current ${className}`}>
@@ -39,6 +39,7 @@ function QueueCard({ label, entry, className = '', playing = false }: { label: s
<span className="text-ui-sm muted truncate">{[song.artist, song.sourceName, song.note].filter(Boolean).join(' · ')}</span>
</div>
{playing && <PlayingBars />}
<IconButton icon={ListPlus} label={saveToPlaylistLabel(song.title)} onClick={onSave} />
</div>
)
}
@@ -50,6 +51,7 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
const playMode = useStore(roomStore, s => s.room?.playMode ?? 'sequential')
const connected = useStore(roomStore, s => s.status === 'open')
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
const save = (entry: Entry) => setSaving({ track: entry.track, title: entry.meta.title })
return (
<section className={compact ? 'queue-panel' : 'page-content queue-page'} aria-label="播放列表">
<div className="section-heading">
@@ -70,8 +72,8 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
]}
/>
</div>
{nowPlaying && <QueueCard label="正在播放" entry={nowPlaying.entry} playing />}
{pending && <QueueCard label="下一首" entry={pending} className="queue-pending" />}
{nowPlaying && <QueueCard label="正在播放" entry={nowPlaying.entry} playing onSave={() => save(nowPlaying.entry)} />}
{pending && <QueueCard label="下一首" entry={pending} className="queue-pending" onSave={() => save(pending)} />}
{items.length
? (
<SongList
+1 -12
View File
@@ -36,19 +36,8 @@ export function AccountRow() {
}, [])
const logout = async () => {
setBusy(true)
try {
await api.logout()
switchIdentity(null)
}
catch (error) {
// 账号在这台设备上的会话已经失效,客户端也已经换成新的游客,退出登录的目的已经达到。
if (error instanceof IdentityRenewedError) {
window.location.reload()
return
}
notificationStore.error(errorMessage(error))
if (!await profileStore.logout())
setBusy(false)
}
}
const description = !member
? (failed ? '暂时无法获取你的资料。' : '正在获取你的资料…')
+129 -1
View File
@@ -30,9 +30,13 @@ const member = { id: 'm1', name: 'alice', avatarHash: '' }
const renewed = { id: 'm2', name: '游客-1a2b', avatarHash: '' }
let local: FakeStorage
let storageListeners: ((event: { key: string }) => void)[]
let reload: ReturnType<typeof vi.fn>
let me: unknown
let updates: unknown[]
let patchFails: boolean
let patchError: { status: number, code: string } | null
let extraRoutes: Map<string, () => Response>
async function load() {
const [{ profileStore, switchIdentity }, { identityEvents }] = await Promise.all([import('./profile-store'), import('@/core/api')])
@@ -47,15 +51,26 @@ beforeEach(() => {
reload = vi.fn()
me = { member }
updates = []
patchFails = false
patchError = null
extraRoutes = new Map()
vi.stubGlobal('localStorage', local)
vi.stubGlobal('sessionStorage', new FakeStorage())
vi.stubGlobal('window', { location: { reload } })
storageListeners = []
vi.stubGlobal('window', { location: { reload }, addEventListener: (_type: string, listener: (event: { key: string }) => void) => storageListeners.push(listener) })
vi.stubGlobal('fetch', (path: string, init: RequestInit) => {
const request = `${init.method} ${path}`
if (request === 'PATCH /api/me') {
if (patchFails)
return Promise.reject(new TypeError('offline'))
updates.push(JSON.parse(String(init.body)))
if (patchError)
return Promise.resolve(new Response(JSON.stringify({ error: { code: patchError.code, message: '邮箱格式不对' } }), { status: patchError.status }))
return Promise.resolve(new Response(JSON.stringify({ member }), { status: 200 }))
}
const extra = extraRoutes.get(request)
if (extra)
return Promise.resolve(extra())
if (request !== 'GET /api/me')
throw new Error(`没有预期的请求 ${request}`)
return Promise.resolve(new Response(JSON.stringify(me), { status: 200 }))
@@ -99,6 +114,50 @@ describe('switchIdentity', () => {
})
})
describe('其他标签页更换身份', () => {
it('其他标签页换了 token 之后重新加载页面', async () => {
local.setItem(memberTokenKey, 'good')
const { profileStore } = await load()
profileStore.start()
await profileStore.load()
local.setItem(memberTokenKey, 'other')
for (const listener of storageListeners) listener({ key: memberTokenKey })
expect(reload).toHaveBeenCalledOnce()
})
})
describe('退出登录', () => {
beforeEach(() => {
local.setItem(memberTokenKey, 'account')
})
it('成功后删除 token 并重新加载页面', async () => {
extraRoutes.set('DELETE /api/sessions/current', () => new Response(null, { status: 204 }))
const { profileStore } = await load()
await profileStore.logout()
expect(local.getItem(memberTokenKey)).toBeNull()
expect(reload).toHaveBeenCalledOnce()
})
it('会话已经失效、客户端换成了新的游客时,重新加载页面', async () => {
extraRoutes.set('DELETE /api/sessions/current', () => new Response(JSON.stringify({ error: { code: 'UNAUTHORIZED', message: '身份已失效' } }), { status: 401 }))
extraRoutes.set('POST /api/members', () => new Response(JSON.stringify({ member: renewed, memberToken: 'guest' }), { status: 200 }))
me = { member: renewed }
const { profileStore } = await load()
await profileStore.logout()
expect(local.getItem(memberTokenKey)).toBe('guest')
expect(reload).toHaveBeenCalledOnce()
})
it('其他错误不重新加载页面,token 保留', async () => {
extraRoutes.set('DELETE /api/sessions/current', () => new Response(JSON.stringify({ error: { code: 'INTERNAL', message: '出错了' } }), { status: 500 }))
const { profileStore } = await load()
expect(await profileStore.logout()).toBe(false)
expect(local.getItem(memberTokenKey)).toBe('account')
expect(reload).not.toHaveBeenCalled()
})
})
describe('账号状态', () => {
beforeEach(() => {
local.setItem(memberTokenKey, 'good')
@@ -151,6 +210,7 @@ describe('邮箱', () => {
await profileStore.load()
expect(profileStore.state.email).toBe('Alice@Example.COM')
local.removeItem(emailKey)
me = { member, account: { username: 'alice' } }
await profileStore.load()
expect(profileStore.state.email).toBe('')
@@ -205,4 +265,72 @@ describe('邮箱', () => {
await profileStore.registered({ username: 'alice' })
expect(updates).toEqual([])
})
it('注册时转存失败,本机的副本保留,下次加载时重试并删除副本', async () => {
local.setItem(emailKey, 'guest@example.com')
patchFails = true
const { profileStore } = await load()
profileStore.start()
await profileStore.registered({ username: 'alice' })
expect(local.getItem(emailKey)).toBe('guest@example.com')
patchFails = false
me = { member, account: { username: 'alice' } }
await profileStore.load()
expect(updates).toEqual([{ email: 'guest@example.com' }])
expect(local.getItem(emailKey)).toBeNull()
expect(profileStore.state).toMatchObject({ email: 'guest@example.com', account: { username: 'alice', email: 'guest@example.com' } })
})
it('服务端判定本机的邮箱不合格时删除副本,之后加载不再转存', async () => {
local.setItem(emailKey, 'legacy')
patchError = { status: 400, code: 'INVALID_ARGUMENT' }
me = { member, account: { username: 'alice' } }
const { profileStore } = await load()
await profileStore.load()
expect(local.getItem(emailKey)).toBeNull()
expect(updates).toEqual([{ email: 'legacy' }])
await profileStore.load()
expect(updates).toEqual([{ email: 'legacy' }])
})
it('重试仍失败时保留本机的副本,下次加载再试', async () => {
local.setItem(emailKey, 'guest@example.com')
patchFails = true
me = { member, account: { username: 'alice' } }
const { profileStore } = await load()
await profileStore.load()
expect(local.getItem(emailKey)).toBe('guest@example.com')
patchFails = false
await profileStore.load()
expect(local.getItem(emailKey)).toBeNull()
expect(updates).toEqual([{ email: 'guest@example.com' }])
})
it('已注册且本机没有副本,或账号已有邮箱时,加载不发更新请求', async () => {
me = { member, account: { username: 'alice' } }
const { profileStore } = await load()
await profileStore.load()
local.setItem(emailKey, 'guest@example.com')
me = { member, account: { username: 'alice', email: 'alice@example.com' } }
await profileStore.load()
expect(updates).toEqual([])
})
it('用户清空账号的邮箱时一并删除本机残留的副本,之后加载不再转存', async () => {
local.setItem(emailKey, 'guest@example.com')
me = { member, account: { username: 'alice', email: 'alice@example.com' } }
const { profileStore } = await load()
await profileStore.load()
expect(await profileStore.save({ email: '' })).toBe(true)
expect(local.getItem(emailKey)).toBeNull()
updates.length = 0
me = { member, account: { username: 'alice' } }
await profileStore.load()
expect(updates).toEqual([])
})
})
+45 -7
View File
@@ -1,6 +1,6 @@
import type { IdentityEvent } from '@/core/api'
import type { Account, Member } from '@/core/realtime'
import { api, deleteAllTickets, deleteMemberToken, identityEvents, IdentityRenewedError, saveMemberToken } from '@/core/api'
import { api, ApiError, deleteAllTickets, deleteMemberToken, followMemberTokenChanges, identityEvents, IdentityRenewedError, saveMemberToken } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { Store } from '@/shared/lib/store'
import { notificationStore } from '@/shared/ui'
@@ -18,6 +18,8 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
start(): void {
this.setState({ email: localStorage.getItem(emailKey) ?? '' })
identityEvents.on(this.#onIdentity)
// 其他标签页登录或退出之后,本标签页的资料、房间连接都属于旧身份,重新加载页面换成新身份。
followMemberTokenChanges(() => window.location.reload())
}
load(): Promise<void> {
@@ -27,21 +29,45 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
return this.#loading
}
// 本机已有的邮箱存进新账号,存失败只提示,不影响注册。
// 本机已有的邮箱存进新账号,存失败只提示,不影响注册;本机的副本保留,下次加载时重试。
async registered(account: Account): Promise<void> {
this.setState({ account })
const email = localStorage.getItem(emailKey)
if (email && await this.save({ email }))
localStorage.removeItem(emailKey)
await this.#moveLocalEmail()
}
// 返回 false 时,失败原因已经通过通知显示。
async logout(): Promise<boolean> {
try {
await api.logout()
switchIdentity(null)
}
catch (error) {
// 账号在这台设备上的会话已经失效,客户端也已经换成新的游客,退出登录的目的已经达到。
if (error instanceof IdentityRenewedError) {
window.location.reload()
return true
}
notificationStore.error(errorMessage(error))
return false
}
return true
}
// 返回 false 时,失败原因已经通过通知显示。
async save(changes: { name?: string, email?: string }): Promise<boolean> {
return await this.#update(changes) === null
}
// 失败时返回错误,失败原因已经通过通知显示。
async #update(changes: { name?: string, email?: string }): Promise<unknown> {
try {
const { member } = await api.updateMe(changes)
const { account } = this.state
if (account) {
const email = changes.email?.trim() ?? this.state.email
// 用户对账号邮箱的修改是最终决定,本机残留的副本不能再覆盖它。
if (changes.email !== undefined)
localStorage.removeItem(emailKey)
this.setState({ member, email, account: { username: account.username, ...(email && { email }) }, failed: false })
}
else {
@@ -49,12 +75,12 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
localStorage.setItem(emailKey, email)
this.setState({ member, email, failed: false })
}
return true
return null
}
catch (error) {
// 客户端不能按服务端的 message 区分是哪个字段不合格。
notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空;邮箱要写成 name@example.com 这样的格式。' }))
return false
return error
}
}
@@ -63,6 +89,8 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
try {
const { member, account } = await api.getMe()
this.setState({ member, account: account ?? null, email: account ? account.email ?? '' : localStorage.getItem(emailKey) ?? '', failed: false })
if (account && !account.email)
await this.#moveLocalEmail()
}
catch (error) {
// 成员 token 被拒绝时身份已经更换,新的成员由身份事件设置。
@@ -73,6 +101,16 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
}
}
async #moveLocalEmail(): Promise<void> {
const email = localStorage.getItem(emailKey)
if (!email)
return
const error = await this.#update({ email })
// 服务端不接受的副本重试也不会成功,删掉免得每次加载都报错;其他错误(网络、服务端故障)留到下次再试。
if (error instanceof ApiError && error.code === 'INVALID_ARGUMENT')
localStorage.removeItem(emailKey)
}
#onIdentity = (event: IdentityEvent): void => {
switch (event.kind) {
case 'rejected':
+1
View File
@@ -108,3 +108,4 @@
.modal-body { padding: 20px 20px calc(20px + env(safe-area-inset-bottom)); }
.notifications { --notification-top: calc(76px + env(safe-area-inset-top)); }
}
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }