From 1157843f5077469995e34b7d0846251072309910 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Wed, 11 Nov 2026 00:15:01 +0800 Subject: [PATCH] feat: expose device pairing over HTTP --- internal/adapter/in/api/account.go | 33 +++- internal/adapter/in/api/api.go | 6 + internal/adapter/in/api/pairing_test.go | 48 ++++++ internal/adapter/in/api/qrlogin.go | 13 +- internal/bootstrap/public.go | 1 + .../bootstrap/testdata/http/account.txtar | 14 +- .../testdata/http/login-device.txtar | 6 +- .../bootstrap/testdata/http/pairing.txtar | 133 +++++++++++++++ .../testdata/http/playlist-imports.txtar | 2 +- .../testdata/http/playlist-shares.txtar | 4 +- .../bootstrap/testdata/http/playlists.txtar | 4 +- internal/bootstrap/testdata/http/site.txtar | 2 +- .../testdata/ws/account-sessions.txtar | 4 +- internal/bootstrap/testdata/ws/listens.txtar | 4 +- protocol/decode.gen.go | 45 ++++- protocol/idl.json | 65 ++++++- protocol/openapi.gen.json | 160 +++++++++++++++++- protocol/types.gen.go | 25 ++- web/src/core/api/http.gen.ts | 20 +++ web/src/core/realtime/protocol.gen.ts | 5 +- .../playlists/playlists-store.test.ts | 14 +- .../features/settings/profile-store.test.ts | 48 +++--- web/src/features/settings/profile-store.ts | 6 +- 23 files changed, 587 insertions(+), 75 deletions(-) create mode 100644 internal/adapter/in/api/pairing_test.go create mode 100644 internal/bootstrap/testdata/http/pairing.txtar diff --git a/internal/adapter/in/api/account.go b/internal/adapter/in/api/account.go index 121e10c..12d340d 100644 --- a/internal/adapter/in/api/account.go +++ b/internal/adapter/in/api/account.go @@ -70,8 +70,39 @@ func (s *server) adminResetAccountPassword(ctx context.Context, c call) (any, er return protocol.AdminResetAccountPasswordResponse{Password: password}, nil } +func (s *server) createPairingCode(ctx context.Context, c call) (any, error) { + if _, err := protocol.DecodeCreatePairingCodeRequest(c.body); err != nil { + return nil, err + } + code, err := s.Accounts.CreatePairingCode(ctx, c.member.ID, bearer(c.r)) + if err != nil { + return nil, err + } + // 配对码放在片段里,不会出现在请求和 Referer 中。 + image, err := qrDataURI(s.PublicURL + "/pair#" + code.Code) + if err != nil { + return nil, err + } + return protocol.CreatePairingCodeResponse{Code: code.Code, QrImage: image, ExpiresAtMs: code.ExpiresAt.UnixMilli()}, nil +} + +func (s *server) redeemPairingCode(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeRedeemPairingCodeRequest(c.body) + if err != nil { + return nil, err + } + session, account, err := s.Accounts.RedeemPairingCode(ctx, c.member, bearer(c.r), c.clientIP, req.Code) + if err != nil { + return nil, err + } + return protocol.RedeemPairingCodeResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account)}, nil +} + func accountDTO(a identity.Account) protocol.Account { - dto := protocol.Account{Username: a.Username} + dto := protocol.Account{Github: a.GitHub} + if a.Username != "" { + dto.Username = &a.Username + } if a.Email != "" { dto.Email = &a.Email } diff --git a/internal/adapter/in/api/api.go b/internal/adapter/in/api/api.go index 741dddf..9b82ff2 100644 --- a/internal/adapter/in/api/api.go +++ b/internal/adapter/in/api/api.go @@ -57,6 +57,8 @@ type Services struct { // 浏览器访问对象存储时使用的地址前缀,结尾没有 /。 StorageURL string + // 网页的 origin,结尾没有 /。 + PublicURL string } type server struct { @@ -169,6 +171,8 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider, srv.handle(mux, protocol.RouteUnshareMyPlaylist, srv.unshareMyPlaylist) srv.handle(mux, protocol.RouteGetSharedPlaylist, srv.getSharedPlaylist) srv.handle(mux, protocol.RouteCopySharedPlaylist, srv.copySharedPlaylist) + srv.handle(mux, protocol.RouteCreatePairingCode, srv.createPairingCode) + srv.handle(mux, protocol.RouteRedeemPairingCode, srv.redeemPairingCode) mux.HandleFunc("GET /ws", srv.websocket) mux.HandleFunc("GET /api/room/events", srv.roomEvents) mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia) @@ -357,6 +361,8 @@ var knownErrors = []struct { {errClientOutdated, protocol.ErrorCodeClientOutdated}, {errPanicked, protocol.ErrorCodeInternal}, {port.ErrShareNotFound, protocol.ErrorCodeNotFound}, + {identity.ErrInvalidPairingCode, protocol.ErrorCodeInvalidCredentials}, + {port.ErrNoPassword, protocol.ErrorCodeConflict}, } func errorInfo(err error) (info protocol.ErrorInfo, expected bool) { diff --git a/internal/adapter/in/api/pairing_test.go b/internal/adapter/in/api/pairing_test.go new file mode 100644 index 0000000..79bdea9 --- /dev/null +++ b/internal/adapter/in/api/pairing_test.go @@ -0,0 +1,48 @@ +package api + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "testing" + "time" + + "rsc.io/qr" + + "github.com/dihemoe/alisten/internal/app/identity" + "github.com/dihemoe/alisten/internal/app/port/porttest" + "github.com/dihemoe/alisten/protocol" +) + +type unlimited struct{} + +func (unlimited) Take(context.Context, string, int, time.Duration) (time.Duration, error) { + return 0, nil +} + +// 二维码的内容是网页上兑换配对码的地址,配对码放在片段里。 +func TestPairingCodeQRImage(t *testing.T) { + store := porttest.New() + s := services(store, store, "") + s.Accounts = identity.NewAccounts(store, store, unlimited{}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), s.Gateway) + s.PublicURL = "https://alisten.example" + server, _ := start(t, s) + token := memberToken(t, server) + auth := []string{"Authorization", "Bearer " + token} + if got := send(t, server, http.MethodPost, "/api/account", `{"username":"alice","password":"correct horse"}`, auth...); got.status != http.StatusCreated { + t.Fatalf("注册 = %#v", got) + } + got := send(t, server, http.MethodPost, "/api/account/pairing-codes", "{}", auth...) + var created protocol.CreatePairingCodeResponse + if err := json.Unmarshal([]byte(got.body), &created); err != nil || got.status != http.StatusCreated { + t.Fatalf("生成配对码 = %#v", got) + } + code, err := qr.Encode("https://alisten.example/pair#"+created.Code, qr.M) + if err != nil { + t.Fatal(err) + } + if want := "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG()); created.QrImage != want { + t.Errorf("二维码 = %.60q…,期望内容为 https://alisten.example/pair#%s 的 PNG", created.QrImage, created.Code) + } +} diff --git a/internal/adapter/in/api/qrlogin.go b/internal/adapter/in/api/qrlogin.go index d97b4d5..7f27a1b 100644 --- a/internal/adapter/in/api/qrlogin.go +++ b/internal/adapter/in/api/qrlogin.go @@ -87,15 +87,22 @@ func qrLogin(session port.QRLoginSession) (protocol.QRLogin, error) { if session.LoginURL == "" { return protocol.QRLogin{}, fmt.Errorf("音源没有给出二维码图片和登录链接:%w", port.ErrProviderFailed) } - code, err := qr.Encode(session.LoginURL, qr.M) - if err != nil { + var err error + if image, err = qrDataURI(session.LoginURL); err != nil { return protocol.QRLogin{}, fmt.Errorf("把登录链接编码成二维码:%w:%w", err, port.ErrProviderFailed) } - image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG()) } return protocol.QRLogin{Key: session.Key, QrImage: image, ExpiresAtMs: session.ExpiresAt.UnixMilli()}, nil } +func qrDataURI(text string) (string, error) { + code, err := qr.Encode(text, qr.M) + if err != nil { + return "", err + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG()), nil +} + func qrLoginResult(r port.QRLoginResult) protocol.QRLoginResult { return protocol.QRLoginResult{Status: protocol.QRLoginStatus(r.Status), Message: r.Message} } diff --git a/internal/bootstrap/public.go b/internal/bootstrap/public.go index 25b3efd..12314db 100644 --- a/internal/bootstrap/public.go +++ b/internal/bootstrap/public.go @@ -95,6 +95,7 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc Listens: listen.New(store), StorageURL: cfg.storageURL, + PublicURL: cfg.publicOrigin, } httpLogger := base.With("component", "http") return public{ diff --git a/internal/bootstrap/testdata/http/account.txtar b/internal/bootstrap/testdata/http/account.txtar index 6938ed8..f870932 100644 --- a/internal/bootstrap/testdata/http/account.txtar +++ b/internal/bootstrap/testdata/http/account.txtar @@ -15,14 +15,14 @@ Authorization: Bearer $alice {"username": "alice", "password": "correct horse"} -- response -- 201 -{"account": {"username": "alice"}} +{"account": {"username": "alice", "github": false}} -- request -- GET /api/me Authorization: Bearer $alice -- response -- 200 -{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "recordListens": true} +{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "recordListens": true} -- request -- PATCH /api/me @@ -38,7 +38,7 @@ GET /api/me Authorization: Bearer $alice -- response -- 200 -{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}, "recordListens": true} +{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM", "github": false}, "recordListens": true} -- request -- POST /api/account @@ -132,14 +132,14 @@ Authorization: Bearer $bob {"username": "alice", "password": "correct horse"} -- response -- 201 -{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}, "device": "$bobDevice"} +{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM", "github": false}, "device": "$bobDevice"} -- request -- GET /api/me Authorization: Bearer $alice2 -- response -- 200 -{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}, "recordListens": true} +{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM", "github": false}, "recordListens": true} -- request -- PATCH /api/me @@ -155,7 +155,7 @@ GET /api/me Authorization: Bearer $alice -- response -- 200 -{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "recordListens": true} +{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "recordListens": true} -- request -- GET /api/me @@ -255,4 +255,4 @@ Authorization: Bearer $dave {"username": "alice", "password": "$resetPassword"} -- response -- 201 -{"memberToken": "$alice4", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "device": "$daveDevice"} +{"memberToken": "$alice4", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "device": "$daveDevice"} diff --git a/internal/bootstrap/testdata/http/login-device.txtar b/internal/bootstrap/testdata/http/login-device.txtar index 66e500c..833b03b 100644 --- a/internal/bootstrap/testdata/http/login-device.txtar +++ b/internal/bootstrap/testdata/http/login-device.txtar @@ -16,7 +16,7 @@ Authorization: Bearer $alice {"username": "alice", "password": "correct horse"} -- response -- 201 -{"account": {"username": "alice"}} +{"account": {"username": "alice", "github": false}} -- request -- POST /api/members @@ -33,7 +33,7 @@ Authorization: Bearer $laptop {"username": "alice", "password": "correct horse"} -- response -- 201 -{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "device": "$device"} +{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "device": "$device"} -- request -- DELETE /api/sessions/current @@ -127,7 +127,7 @@ Authorization: Bearer $laptop2 {"username": "alice", "password": "correct horse", "device": "$device"} -- response -- 201 -{"memberToken": "$alice3", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "device": "$device"} +{"memberToken": "$alice3", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "device": "$device"} -- request -- PUT /api/account/password diff --git a/internal/bootstrap/testdata/http/pairing.txtar b/internal/bootstrap/testdata/http/pairing.txtar new file mode 100644 index 0000000..d3d26f3 --- /dev/null +++ b/internal/bootstrap/testdata/http/pairing.txtar @@ -0,0 +1,133 @@ +设备配对:已注册的账号生成配对码,游客兑换之后登录这个账号,原来的游客 token 随即失效;配对码只能兑换一次,游客不能生成,生成它的会话退出之后配对码作废。 + +-- request -- +POST /api/members + +{} +-- response -- +201 +{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}} + +-- request -- +POST /api/account/pairing-codes +Authorization: Bearer $alice + +{} +-- response -- +403 +{"error": {"code": "FORBIDDEN", "message": "还没有注册账号"}} + +-- request -- +POST /api/account +Authorization: Bearer $alice + +{"username": "alice", "password": "correct horse"} +-- response -- +201 +{"account": {"username": "alice", "github": false}} + +-- request -- +POST /api/account/pairing-codes +Authorization: Bearer $alice + +{} +-- response -- +201 +{"code": "$code", "qrImage": "$qr", "expiresAtMs": 1} + +-- request -- +POST /api/members + +{} +-- response -- +201 +{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}} + +-- request -- +POST /api/sessions/pairing +Authorization: Bearer $bob + +{"code": "abcd2345"} +-- response -- +400 +{"error": {"code": "INVALID_ARGUMENT", "message": "code:格式不正确"}} + +-- request -- +POST /api/sessions/pairing +Authorization: Bearer $bob + +{"code": "$code"} +-- response -- +201 +{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}} + +-- request -- +GET /api/me +Authorization: Bearer $bob +-- response -- +401 +{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}} + +-- request -- +GET /api/me +Authorization: Bearer $alice2 +-- response -- +200 +{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "recordListens": true} + +-- request -- +POST /api/members + +{} +-- response -- +201 +{"memberToken": "$carol", "member": {"id": "$carolId", "name": "$carolName", "avatarHash": ""}} + +-- request -- +POST /api/sessions/pairing +Authorization: Bearer $carol + +{"code": "$code"} +-- response -- +401 +{"error": {"code": "INVALID_CREDENTIALS", "message": "配对码不对或已经过期"}} + +-- request -- +POST /api/account/pairing-codes +Authorization: Bearer $alice2 + +{} +-- response -- +201 +{"code": "$code2", "qrImage": "$qr2", "expiresAtMs": 1} + +-- request -- +POST /api/sessions/pairing +Authorization: Bearer $alice + +{"code": "$code2"} +-- response -- +409 +{"error": {"code": "CONFLICT", "message": "已经登录了账号,先退出再登录其他账号"}} + +-- request -- +DELETE /api/sessions/current +Authorization: Bearer $alice2 +-- response -- +204 + +-- request -- +POST /api/sessions/pairing +Authorization: Bearer $carol + +{"code": "$code2"} +-- response -- +401 +{"error": {"code": "INVALID_CREDENTIALS", "message": "配对码不对或已经过期"}} + +-- request -- +GET /api/me +Authorization: Bearer $carol +-- response -- +200 +{"member": {"id": "$carolId", "name": "$carolName", "avatarHash": ""}, "recordListens": true} diff --git a/internal/bootstrap/testdata/http/playlist-imports.txtar b/internal/bootstrap/testdata/http/playlist-imports.txtar index 9bbf410..6d557fa 100644 --- a/internal/bootstrap/testdata/http/playlist-imports.txtar +++ b/internal/bootstrap/testdata/http/playlist-imports.txtar @@ -42,7 +42,7 @@ Authorization: Bearer $alice {"username": "alice", "password": "correct horse"} -- response -- 201 -{"account": {"username": "alice"}} +{"account": {"username": "alice", "github": false}} -- request -- POST /api/rooms diff --git a/internal/bootstrap/testdata/http/playlist-shares.txtar b/internal/bootstrap/testdata/http/playlist-shares.txtar index 7d0181a..4f11b6a 100644 --- a/internal/bootstrap/testdata/http/playlist-shares.txtar +++ b/internal/bootstrap/testdata/http/playlist-shares.txtar @@ -15,7 +15,7 @@ Authorization: Bearer $alice {"username": "alice", "password": "correct horse"} -- response -- 201 -{"account": {"username": "alice"}} +{"account": {"username": "alice", "github": false}} -- request -- PATCH /api/me @@ -165,7 +165,7 @@ Authorization: Bearer $bob {"username": "bob", "password": "correct horse"} -- response -- 201 -{"account": {"username": "bob"}} +{"account": {"username": "bob", "github": false}} -- request -- POST /api/me/playlists/$favorites/share diff --git a/internal/bootstrap/testdata/http/playlists.txtar b/internal/bootstrap/testdata/http/playlists.txtar index 8aeb521..a15a52e 100644 --- a/internal/bootstrap/testdata/http/playlists.txtar +++ b/internal/bootstrap/testdata/http/playlists.txtar @@ -31,7 +31,7 @@ Authorization: Bearer $alice {"username": "alice", "password": "correct horse"} -- response -- 201 -{"account": {"username": "alice"}} +{"account": {"username": "alice", "github": false}} -- request -- GET /api/me/playlists @@ -282,7 +282,7 @@ Authorization: Bearer $bob {"username": "bob", "password": "correct horse"} -- response -- 201 -{"account": {"username": "bob"}} +{"account": {"username": "bob", "github": false}} -- request -- GET /api/me/playlists diff --git a/internal/bootstrap/testdata/http/site.txtar b/internal/bootstrap/testdata/http/site.txtar index 5d484a7..1a98d4c 100644 --- a/internal/bootstrap/testdata/http/site.txtar +++ b/internal/bootstrap/testdata/http/site.txtar @@ -106,4 +106,4 @@ Authorization: Bearer $guest {"username": "guest", "password": "correct horse"} -- response -- 201 -{"account": {"username": "guest"}} +{"account": {"username": "guest", "github": false}} diff --git a/internal/bootstrap/testdata/ws/account-sessions.txtar b/internal/bootstrap/testdata/ws/account-sessions.txtar index c493922..a4a0b1d 100644 --- a/internal/bootstrap/testdata/ws/account-sessions.txtar +++ b/internal/bootstrap/testdata/ws/account-sessions.txtar @@ -16,7 +16,7 @@ Authorization: Bearer $alice {"username": "alice", "password": "correct horse"} -- response -- 201 -{"account": {"username": "alice"}} +{"account": {"username": "alice", "github": false}} -- request -- POST /api/members @@ -33,7 +33,7 @@ Authorization: Bearer $guest {"username": "alice", "password": "correct horse"} -- response -- 201 -{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "device": "$guestDevice"} +{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "device": "$guestDevice"} -- request -- POST /api/rooms diff --git a/internal/bootstrap/testdata/ws/listens.txtar b/internal/bootstrap/testdata/ws/listens.txtar index 39a0df9..0469958 100644 --- a/internal/bootstrap/testdata/ws/listens.txtar +++ b/internal/bootstrap/testdata/ws/listens.txtar @@ -16,7 +16,7 @@ Authorization: Bearer $alice {"username": "alice", "password": "correct horse"} -- response -- 201 -{"account": {"username": "alice"}} +{"account": {"username": "alice", "github": false}} -- request -- GET /api/me/listens @@ -215,7 +215,7 @@ Authorization: Bearer $bob {"username": "alice", "password": "correct horse"} -- response -- 201 -{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "device": "$bobDevice"} +{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "device": "$bobDevice"} -- closed again -- 4401 -- receive alice -- diff --git a/protocol/decode.gen.go b/protocol/decode.gen.go index 1615239..f070a43 100644 --- a/protocol/decode.gen.go +++ b/protocol/decode.gen.go @@ -8,12 +8,13 @@ import ( ) var ( - patternAdminCreateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{1,32}$`) - patternAdminUpdateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{0,32}$`) - patternLoginRequestDevice = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) - patternLoginRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`) - patternRegisterRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`) - patternTrackIDWhenBilibili = regexp.MustCompile(`^BV[1-9A-HJ-NP-Za-km-z]{10}(?:/([2-9]|[1-9][0-9]{1,3}))?$`) + patternAdminCreateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{1,32}$`) + patternAdminUpdateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{0,32}$`) + patternLoginRequestDevice = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) + patternLoginRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`) + patternRedeemPairingCodeRequestCode = regexp.MustCompile(`^[2-9A-HJ-NP-Z]{8}$`) + patternRegisterRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`) + patternTrackIDWhenBilibili = regexp.MustCompile(`^BV[1-9A-HJ-NP-Za-km-z]{10}(?:/([2-9]|[1-9][0-9]{1,3}))?$`) ) func decodeConnectionRequest(typ string, raw json.RawMessage, path string) (any, bool, error) { @@ -203,6 +204,14 @@ func DecodeCopySharedPlaylistRequest(body []byte) (CopySharedPlaylistRequest, er return decodeCopySharedPlaylistRequest(body, "") } +func DecodeCreatePairingCodeRequest(body []byte) (CreatePairingCodeRequest, error) { + return decodeCreatePairingCodeRequest(body, "") +} + +func DecodeRedeemPairingCodeRequest(body []byte) (RedeemPairingCodeRequest, error) { + return decodeRedeemPairingCodeRequest(body, "") +} + func decodeAddMyPlaylistItemRequest(raw json.RawMessage, path string) (AddMyPlaylistItemRequest, error) { var v AddMyPlaylistItemRequest m, err := decodeObject(raw, path, "track") @@ -554,6 +563,12 @@ func decodeCreateMyPlaylistRequest(raw json.RawMessage, path string) (CreateMyPl return v, nil } +func decodeCreatePairingCodeRequest(raw json.RawMessage, path string) (CreatePairingCodeRequest, error) { + var v CreatePairingCodeRequest + _, err := decodeObject(raw, path) + return v, err +} + func decodeCreateRoomRequest(raw json.RawMessage, path string) (CreateRoomRequest, error) { var v CreateRoomRequest m, err := decodeObject(raw, path, "description", "name", "password") @@ -980,6 +995,24 @@ func decodeRecommendListRequest(raw json.RawMessage, path string) (RecommendList return v, err } +func decodeRedeemPairingCodeRequest(raw json.RawMessage, path string) (RedeemPairingCodeRequest, error) { + var v RedeemPairingCodeRequest + m, err := decodeObject(raw, path, "code") + if err != nil { + return v, err + } + if err = requireFields(m, path, "code"); err != nil { + return v, err + } + if v.Code, err = decodeString(m["code"], field(path, "code")); err != nil { + return v, err + } + if err = checkPattern(v.Code, field(path, "code"), patternRedeemPairingCodeRequestCode); err != nil { + return v, err + } + return v, nil +} + func decodeRegisterRequest(raw json.RawMessage, path string) (RegisterRequest, error) { var v RegisterRequest m, err := decodeObject(raw, path, "password", "username") diff --git a/protocol/idl.json b/protocol/idl.json index 11b4168..4ace03f 100644 --- a/protocol/idl.json +++ b/protocol/idl.json @@ -161,10 +161,11 @@ "type": "object", "additionalProperties": false, "properties": { - "username": {"type": "string"}, - "email": {"type": "string", "description": "账号设置了邮箱时才有"} + "username": {"type": "string", "description": "设置了用户名和密码时才有,只绑定了 GitHub 的账号没有"}, + "email": {"type": "string", "description": "账号设置了邮箱时才有"}, + "github": {"type": "boolean", "description": "绑定了 GitHub"} }, - "required": ["username"] + "required": ["github"] }, "AdminAccount": { "type": "object", @@ -375,6 +376,11 @@ "items": {"type": "array", "items": {"$ref": "#/$defs/MyPlaylistItem"}, "description": "新加入的在前"} }, "required": ["name", "favorites", "ownerName", "ownerAvatarHash", "items"] + }, + "PairingCode": { + "type": "string", + "description": "配对码:8 位,由数字 2–9 和除 I、O 以外的大写字母组成", + "pattern": "^[2-9A-HJ-NP-Z]{8}$" } }, "x-ws": { @@ -795,7 +801,7 @@ }, { "name": "register", - "description": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN", + "description": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN。只绑定了 GitHub 的账号也用它补设用户名和密码,不受注册开关限制", "method": "POST", "path": "/api/account", "auth": "member", @@ -820,7 +826,7 @@ }, { "name": "changePassword", - "description": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS", + "description": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS,账号还没有设置密码时返回 CONFLICT", "method": "PUT", "path": "/api/account/password", "auth": "member", @@ -1686,6 +1692,55 @@ "required": ["name"] }, "response": {"$ref": "#/$defs/MyPlaylist"} + }, + { + "name": "createPairingCode", + "description": "已注册的账号生成配对码,新设备兑换之后登录这个账号:5 分钟内有效,只能兑换一次,发出请求的成员 token 失效时随之作废;游客调用时返回 FORBIDDEN", + "method": "POST", + "path": "/api/account/pairing-codes", + "auth": "member", + "status": 201, + "request": { + "type": "object", + "additionalProperties": false, + "properties": {} + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": {"$ref": "#/$defs/PairingCode"}, + "qrImage": {"type": "string", "description": "内容为 ALISTEN_PUBLIC_URL/pair#<配对码> 的二维码,PNG 的 data: URI"}, + "expiresAtMs": {"type": "integer"} + }, + "required": ["code", "qrImage", "expiresAtMs"] + } + }, + { + "name": "redeemPairingCode", + "description": "游客兑换配对码,登录生成它的账号:返回账号的新成员 token,发出请求的游客 token 随即失效;配对码不对或已过期时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT", + "method": "POST", + "path": "/api/sessions/pairing", + "auth": "member", + "status": 201, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": {"$ref": "#/$defs/PairingCode"} + }, + "required": ["code"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "memberToken": {"type": "string"}, + "member": {"$ref": "#/$defs/Member"}, + "account": {"$ref": "#/$defs/Account"} + }, + "required": ["memberToken", "member", "account"] + } } ] } diff --git a/protocol/openapi.gen.json b/protocol/openapi.gen.json index d65d0c1..2897dfc 100644 --- a/protocol/openapi.gen.json +++ b/protocol/openapi.gen.json @@ -21,12 +21,17 @@ "description": "账号设置了邮箱时才有", "type": "string" }, + "github": { + "description": "绑定了 GitHub", + "type": "boolean" + }, "username": { + "description": "设置了用户名和密码时才有,只绑定了 GitHub 的账号没有", "type": "string" } }, "required": [ - "username" + "github" ], "type": "object" }, @@ -523,6 +528,33 @@ ], "type": "object" }, + "CreatePairingCodeRequest": { + "additionalProperties": false, + "type": "object" + }, + "CreatePairingCodeResponse": { + "additionalProperties": false, + "properties": { + "code": { + "description": "配对码:8 位,由数字 2–9 和除 I、O 以外的大写字母组成", + "pattern": "^[2-9A-HJ-NP-Z]{8}$", + "type": "string" + }, + "expiresAtMs": { + "type": "integer" + }, + "qrImage": { + "description": "内容为 ALISTEN_PUBLIC_URL/pair#<配对码> 的二维码,PNG 的 data: URI", + "type": "string" + } + }, + "required": [ + "code", + "qrImage", + "expiresAtMs" + ], + "type": "object" + }, "CreateRoomRequest": { "additionalProperties": false, "properties": { @@ -1425,6 +1457,40 @@ ], "type": "object" }, + "RedeemPairingCodeRequest": { + "additionalProperties": false, + "properties": { + "code": { + "description": "配对码:8 位,由数字 2–9 和除 I、O 以外的大写字母组成", + "pattern": "^[2-9A-HJ-NP-Z]{8}$", + "type": "string" + } + }, + "required": [ + "code" + ], + "type": "object" + }, + "RedeemPairingCodeResponse": { + "additionalProperties": false, + "properties": { + "account": { + "$ref": "#/components/schemas/Account" + }, + "member": { + "$ref": "#/components/schemas/Member" + }, + "memberToken": { + "type": "string" + } + }, + "required": [ + "memberToken", + "member", + "account" + ], + "type": "object" + }, "RegisterRequest": { "additionalProperties": false, "properties": { @@ -2033,7 +2099,7 @@ }, "info": { "title": "Alisten", - "version": "cbfa923a9cbe9f0a" + "version": "0023496d94e1b43f" }, "openapi": "3.1.0", "paths": { @@ -2077,7 +2143,50 @@ "memberToken": [] } ], - "summary": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN" + "summary": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN。只绑定了 GitHub 的账号也用它补设用户名和密码,不受注册开关限制" + } + }, + "/api/account/pairing-codes": { + "post": { + "operationId": "createPairingCode", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePairingCodeRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePairingCodeResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "已注册的账号生成配对码,新设备兑换之后登录这个账号:5 分钟内有效,只能兑换一次,发出请求的成员 token 失效时随之作废;游客调用时返回 FORBIDDEN" } }, "/api/account/password": { @@ -2113,7 +2222,7 @@ "memberToken": [] } ], - "summary": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS" + "summary": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS,账号还没有设置密码时返回 CONFLICT" } }, "/api/admin/accounts/{username}": { @@ -4772,6 +4881,49 @@ "summary": "退出登录:发出请求的成员 token 随即失效" } }, + "/api/sessions/pairing": { + "post": { + "operationId": "redeemPairingCode", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RedeemPairingCodeRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RedeemPairingCodeResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "游客兑换配对码,登录生成它的账号:返回账号的新成员 token,发出请求的游客 token 随即失效;配对码不对或已过期时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT" + } + }, "/api/shared-playlists/{shareId}": { "get": { "operationId": "getSharedPlaylist", diff --git a/protocol/types.gen.go b/protocol/types.gen.go index d9b0d42..dff1789 100644 --- a/protocol/types.gen.go +++ b/protocol/types.gen.go @@ -4,7 +4,7 @@ package protocol import "encoding/json" -const Version = "cbfa923a9cbe9f0a" +const Version = "0023496d94e1b43f" type APIKey struct { Key string `json:"key"` @@ -12,7 +12,8 @@ type APIKey struct { type Account struct { Email *string `json:"email,omitempty"` - Username string `json:"username"` + Github bool `json:"github"` + Username *string `json:"username,omitempty"` } type ActivityKind string @@ -196,6 +197,14 @@ type CreateMyPlaylistRequest struct { Name string `json:"name"` } +type CreatePairingCodeRequest struct{} + +type CreatePairingCodeResponse struct { + Code string `json:"code"` + ExpiresAtMs int64 `json:"expiresAtMs"` + QrImage string `json:"qrImage"` +} + type CreateRoomRequest struct { Description string `json:"description"` Name string `json:"name"` @@ -598,6 +607,16 @@ func (v RecommendListResponse) MarshalJSON() ([]byte, error) { return json.Marshal(plain(v)) } +type RedeemPairingCodeRequest struct { + Code string `json:"code"` +} + +type RedeemPairingCodeResponse struct { + Account Account `json:"account"` + Member Member `json:"member"` + MemberToken string `json:"memberToken"` +} + type RegisterRequest struct { Password string `json:"password"` Username string `json:"username"` @@ -926,6 +945,8 @@ var ( RouteUnshareMyPlaylist = Route{Pattern: "DELETE /api/me/playlists/{id}/share", Auth: AuthMember, Status: 204} RouteGetSharedPlaylist = Route{Pattern: "GET /api/shared-playlists/{shareId}", Auth: AuthNone, Status: 200} RouteCopySharedPlaylist = Route{Pattern: "POST /api/shared-playlists/{shareId}/copies", Auth: AuthMember, Status: 201} + RouteCreatePairingCode = Route{Pattern: "POST /api/account/pairing-codes", Auth: AuthMember, Status: 201} + RouteRedeemPairingCode = Route{Pattern: "POST /api/sessions/pairing", Auth: AuthMember, Status: 201} ) var RoomRoutes = []RoomRoute{ diff --git a/web/src/core/api/http.gen.ts b/web/src/core/api/http.gen.ts index b61682e..26cfddc 100644 --- a/web/src/core/api/http.gen.ts +++ b/web/src/core/api/http.gen.ts @@ -133,6 +133,14 @@ export interface CreateMyPlaylistRequest { name: string } +export type CreatePairingCodeRequest = Record + +export interface CreatePairingCodeResponse { + code: string + expiresAtMs: number + qrImage: string +} + export interface CreateRoomRequest { description: string name: string @@ -227,6 +235,16 @@ export interface MoveMyPlaylistItemsRequest { tracks: Track[] } +export interface RedeemPairingCodeRequest { + code: string +} + +export interface RedeemPairingCodeResponse { + account: Account + member: Member + memberToken: string +} + export interface RegisterRequest { password: string username: string @@ -348,5 +366,7 @@ export function httpClient(send: HttpSend) { unshareMyPlaylist: (id: string) => send({ method: 'DELETE', path: `/api/me/playlists/${encodeURIComponent(id)}/share`, auth: 'member' }), getSharedPlaylist: (shareId: string) => send({ method: 'GET', path: `/api/shared-playlists/${encodeURIComponent(shareId)}`, auth: 'none' }), copySharedPlaylist: (shareId: string, body: CopySharedPlaylistRequest) => send({ method: 'POST', path: `/api/shared-playlists/${encodeURIComponent(shareId)}/copies`, auth: 'member', body }), + createPairingCode: () => send({ method: 'POST', path: '/api/account/pairing-codes', auth: 'member', body: {} }), + redeemPairingCode: (body: RedeemPairingCodeRequest) => send({ method: 'POST', path: '/api/sessions/pairing', auth: 'member', body }), } } diff --git a/web/src/core/realtime/protocol.gen.ts b/web/src/core/realtime/protocol.gen.ts index 8047f9a..0129434 100644 --- a/web/src/core/realtime/protocol.gen.ts +++ b/web/src/core/realtime/protocol.gen.ts @@ -1,6 +1,6 @@ // Code generated by protocolgen from protocol/idl.json; DO NOT EDIT. -export const PROTOCOL_VERSION = 'cbfa923a9cbe9f0a' +export const PROTOCOL_VERSION = '0023496d94e1b43f' export interface APIKey { key: string @@ -8,7 +8,8 @@ export interface APIKey { export interface Account { email?: string - username: string + github: boolean + username?: string } export type ActivityKind = 'added' | 'removed' | 'liked' | 'voted' | 'skipped' | 'failed' diff --git a/web/src/features/playlists/playlists-store.test.ts b/web/src/features/playlists/playlists-store.test.ts index 9479683..a548ac9 100644 --- a/web/src/features/playlists/playlists-store.test.ts +++ b/web/src/features/playlists/playlists-store.test.ts @@ -464,7 +464,7 @@ describe('playlistsStore', () => { const { playlistsStore } = await import('./playlists-store') const { profileStore } = await import('@/features/settings') playlistsStore.start() - await profileStore.registered({ username: 'alice' }) + await profileStore.registered({ username: 'alice', github: false }) await flush() expect(playlistsStore.state.playlists).toEqual([favorites]) window.dispatchEvent(new Event('focus')) @@ -477,8 +477,8 @@ describe('playlistsStore', () => { const { profileStore } = await import('@/features/settings') holdReads = true playlistsStore.start() - await profileStore.registered({ username: 'alice' }) - await profileStore.registered({ username: 'bob' }) + await profileStore.registered({ username: 'alice', github: false }) + await profileStore.registered({ username: 'bob', github: false }) await flush() release.shift()!() await flush() @@ -493,12 +493,12 @@ describe('playlistsStore', () => { const { profileStore } = await import('@/features/settings') const { notificationStore } = await import('@/shared/ui') playlistsStore.start() - await profileStore.registered({ username: 'alice' }) + await profileStore.registered({ username: 'alice', github: false }) await flush() failAdd = true const done = playlistsStore.setMembership(favorites, rain, true) await flush() - await profileStore.registered({ username: 'bob' }) + await profileStore.registered({ username: 'bob', github: false }) release.shift()!() await done expect(notificationStore.state.notifications).toEqual([]) @@ -508,12 +508,12 @@ describe('playlistsStore', () => { const { playlistsStore } = await import('./playlists-store') const { profileStore } = await import('@/features/settings') playlistsStore.start() - await profileStore.registered({ username: 'alice' }) + await profileStore.registered({ username: 'alice', github: false }) await flush() holdReads = true const reading = playlistsStore.containing(sunny) await flush() - await profileStore.registered({ username: 'bob' }) + await profileStore.registered({ username: 'bob', github: false }) release.shift()!() await reading expect(playlistsStore.state.memberships.size).toBe(0) diff --git a/web/src/features/settings/profile-store.test.ts b/web/src/features/settings/profile-store.test.ts index 4da5002..89d325f 100644 --- a/web/src/features/settings/profile-store.test.ts +++ b/web/src/features/settings/profile-store.test.ts @@ -167,15 +167,15 @@ describe('账号状态', () => { }) it('取回资料时一并取回账号', async () => { - me = { member, account: { username: 'alice' } } + me = { member, account: { username: 'alice', github: false } } const { profileStore } = await load() await profileStore.load() - expect(profileStore.state).toMatchObject({ member, account: { username: 'alice' } }) + expect(profileStore.state).toMatchObject({ member, account: { username: 'alice', github: false } }) }) it('游客没有账号', async () => { const { profileStore } = await load() - await profileStore.registered({ username: 'alice' }) + await profileStore.registered({ username: 'alice', github: false }) await profileStore.load() expect(profileStore.state).toMatchObject({ member, account: null }) }) @@ -183,18 +183,18 @@ describe('账号状态', () => { it('注册之后设置账号', async () => { const { profileStore } = await load() await profileStore.load() - await profileStore.registered({ username: 'alice' }) - expect(profileStore.state).toMatchObject({ member, account: { username: 'alice' } }) + await profileStore.registered({ username: 'alice', github: false }) + expect(profileStore.state).toMatchObject({ member, account: { username: 'alice', github: false } }) }) it('身份被拒绝或更换之后没有账号', async () => { const { profileStore, identityEvents } = await load() profileStore.start() - await profileStore.registered({ username: 'alice' }) + await profileStore.registered({ username: 'alice', github: false }) identityEvents.emit({ kind: 'rejected' }) expect(profileStore.state).toMatchObject({ member: null, account: null }) - await profileStore.registered({ username: 'alice' }) + await profileStore.registered({ username: 'alice', github: false }) identityEvents.emit({ kind: 'renewed', member: renewed }) expect(profileStore.state).toMatchObject({ member: renewed, account: null }) }) @@ -207,14 +207,14 @@ describe('邮箱', () => { it('已注册时取自账号,不读本机', async () => { local.setItem(emailKey, 'local@example.com') - me = { member, account: { username: 'alice', email: 'Alice@Example.COM' } } + me = { member, account: { username: 'alice', email: 'Alice@Example.COM', github: false } } const { profileStore } = await load() profileStore.start() await profileStore.load() expect(profileStore.state.email).toBe('Alice@Example.COM') local.removeItem(emailKey) - me = { member, account: { username: 'alice' } } + me = { member, account: { username: 'alice', github: false } } await profileStore.load() expect(profileStore.state.email).toBe('') }) @@ -228,18 +228,18 @@ describe('邮箱', () => { }) it('已注册时保存到账号,不写本机', async () => { - me = { member, account: { username: 'alice' } } + me = { member, account: { username: 'alice', github: false } } const { profileStore } = await load() await profileStore.load() expect(await profileStore.save({ email: ' Alice@Example.COM ' })).toBe(true) expect(updates).toEqual([{ email: ' Alice@Example.COM ' }]) - expect(profileStore.state).toMatchObject({ email: 'Alice@Example.COM', account: { username: 'alice', email: 'Alice@Example.COM' } }) + expect(profileStore.state).toMatchObject({ email: 'Alice@Example.COM', account: { username: 'alice', email: 'Alice@Example.COM', github: false } }) expect(local.getItem(emailKey)).toBeNull() expect(await profileStore.save({ email: '' })).toBe(true) expect(profileStore.state.email).toBe('') - expect(profileStore.state.account).toEqual({ username: 'alice' }) + expect(profileStore.state.account).toEqual({ username: 'alice', github: false }) }) it('游客保存到本机', async () => { @@ -255,17 +255,17 @@ describe('邮箱', () => { const { profileStore } = await load() profileStore.start() await profileStore.load() - await profileStore.registered({ username: 'alice' }) + await profileStore.registered({ username: 'alice', github: false }) 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' } }) + expect(profileStore.state).toMatchObject({ email: 'guest@example.com', account: { username: 'alice', email: 'guest@example.com', github: false } }) }) it('注册时本机没有邮箱就不发请求', async () => { const { profileStore } = await load() await profileStore.load() - await profileStore.registered({ username: 'alice' }) + await profileStore.registered({ username: 'alice', github: false }) expect(updates).toEqual([]) }) @@ -274,21 +274,21 @@ describe('邮箱', () => { patchFails = true const { profileStore } = await load() profileStore.start() - await profileStore.registered({ username: 'alice' }) + await profileStore.registered({ username: 'alice', github: false }) expect(local.getItem(emailKey)).toBe('guest@example.com') patchFails = false - me = { member, account: { username: 'alice' } } + me = { member, account: { username: 'alice', github: false } } 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' } }) + expect(profileStore.state).toMatchObject({ email: 'guest@example.com', account: { username: 'alice', email: 'guest@example.com', github: false } }) }) it('服务端判定本机的邮箱不合格时删除副本,之后加载不再转存', async () => { local.setItem(emailKey, 'legacy') patchError = { status: 400, code: 'INVALID_ARGUMENT' } - me = { member, account: { username: 'alice' } } + me = { member, account: { username: 'alice', github: false } } const { profileStore } = await load() await profileStore.load() expect(local.getItem(emailKey)).toBeNull() @@ -301,7 +301,7 @@ describe('邮箱', () => { it('重试仍失败时保留本机的副本,下次加载再试', async () => { local.setItem(emailKey, 'guest@example.com') patchFails = true - me = { member, account: { username: 'alice' } } + me = { member, account: { username: 'alice', github: false } } const { profileStore } = await load() await profileStore.load() expect(local.getItem(emailKey)).toBe('guest@example.com') @@ -313,26 +313,26 @@ describe('邮箱', () => { }) it('已注册且本机没有副本,或账号已有邮箱时,加载不发更新请求', async () => { - me = { member, account: { username: 'alice' } } + me = { member, account: { username: 'alice', github: false } } const { profileStore } = await load() await profileStore.load() local.setItem(emailKey, 'guest@example.com') - me = { member, account: { username: 'alice', email: 'alice@example.com' } } + me = { member, account: { username: 'alice', email: 'alice@example.com', github: false } } await profileStore.load() expect(updates).toEqual([]) }) it('用户清空账号的邮箱时一并删除本机残留的副本,之后加载不再转存', async () => { local.setItem(emailKey, 'guest@example.com') - me = { member, account: { username: 'alice', email: 'alice@example.com' } } + me = { member, account: { username: 'alice', email: 'alice@example.com', github: false } } 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' } } + me = { member, account: { username: 'alice', github: false } } await profileStore.load() expect(updates).toEqual([]) }) diff --git a/web/src/features/settings/profile-store.ts b/web/src/features/settings/profile-store.ts index 059396c..f10bd6e 100644 --- a/web/src/features/settings/profile-store.ts +++ b/web/src/features/settings/profile-store.ts @@ -90,7 +90,11 @@ class ProfileStore extends Store { // 用户对账号邮箱的修改是最终决定,本机残留的副本不能再覆盖它。 if (changes.email !== undefined) localStorage.removeItem(emailKey) - this.setState({ member, email, account: { username: account.username, ...(email && { email }) }, failed: false }) + const next: Account = { ...account } + delete next.email + if (email) + next.email = email + this.setState({ member, email, account: next, failed: false }) } else { const email = changes.email ?? this.state.email