From 0bc556833559af693dcadcbc85cae83c87588e72 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Sun, 8 Nov 2026 21:46:22 +0800 Subject: [PATCH] feat: store accounts and per-device sessions for members --- db/migrations/00004_member_accounts.sql | 6 + db/queries/members.sql | 21 ++++ .../postgres/internal/query/members.sql.go | 102 ++++++++++++++++- .../out/postgres/internal/query/models.go | 10 +- internal/adapter/out/postgres/members.go | 88 +++++++++++++++ internal/adapter/out/postgres/members_test.go | 104 ++++++++++++++++++ internal/app/port/member.go | 26 ++++- internal/app/port/porttest/store.go | 89 ++++++++++++++- 8 files changed, 437 insertions(+), 9 deletions(-) create mode 100644 db/migrations/00004_member_accounts.sql diff --git a/db/migrations/00004_member_accounts.sql b/db/migrations/00004_member_accounts.sql new file mode 100644 index 0000000..554a45f --- /dev/null +++ b/db/migrations/00004_member_accounts.sql @@ -0,0 +1,6 @@ +-- +goose Up +-- 设置了用户名的成员是已注册的账号,否则是游客。用户名只能由小写字母、数字、下划线和连字符组成,所以按原文唯一即可。 +ALTER TABLE members + ADD COLUMN username text UNIQUE, + ADD COLUMN password_hash text, + ADD CHECK (password_hash IS NULL OR username IS NOT NULL); diff --git a/db/queries/members.sql b/db/queries/members.sql index a6443f7..1b97d2d 100644 --- a/db/queries/members.sql +++ b/db/queries/members.sql @@ -15,3 +15,24 @@ SET name = coalesce(sqlc.narg(name), name), avatar_hash = CASE WHEN sqlc.narg(avatar_hash)::text IS NULL THEN avatar_hash ELSE NULLIF(sqlc.narg(avatar_hash)::text, '') END WHERE id = @id RETURNING *; + +-- name: MemberUsername :one +SELECT username FROM members WHERE id = @id; + +-- name: RegisterMember :execrows +-- 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。 +UPDATE members SET username = @username, password_hash = @password_hash +WHERE id = @id AND username IS NULL; + +-- name: AccountByUsername :one +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: DeleteSession :exec +DELETE FROM member_sessions WHERE token_hash = @token_hash; + +-- name: DeleteMemberSessions :exec +-- keep 为 NULL 时删除这个成员的全部会话。 +DELETE FROM member_sessions WHERE member_id = @member_id AND token_hash IS DISTINCT FROM sqlc.narg(keep)::bytea; diff --git a/internal/adapter/out/postgres/internal/query/members.sql.go b/internal/adapter/out/postgres/internal/query/members.sql.go index 5b89cc3..a74039a 100644 --- a/internal/adapter/out/postgres/internal/query/members.sql.go +++ b/internal/adapter/out/postgres/internal/query/members.sql.go @@ -9,9 +9,27 @@ import ( "context" ) +const accountByUsername = `-- name: AccountByUsername :one +SELECT id, name, avatar_hash, created_at, username, password_hash FROM members WHERE username = $1 +` + +func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Member, error) { + row := q.db.QueryRow(ctx, accountByUsername, username) + var i Member + err := row.Scan( + &i.ID, + &i.Name, + &i.AvatarHash, + &i.CreatedAt, + &i.Username, + &i.PasswordHash, + ) + return i, err +} + const createMember = `-- name: CreateMember :one INSERT INTO members (name) VALUES ($1) -RETURNING id, name, avatar_hash, created_at +RETURNING id, name, avatar_hash, created_at, username, password_hash ` func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) { @@ -22,6 +40,8 @@ func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) &i.Name, &i.AvatarHash, &i.CreatedAt, + &i.Username, + &i.PasswordHash, ) return i, err } @@ -40,8 +60,32 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) er return err } +const deleteMemberSessions = `-- name: DeleteMemberSessions :exec +DELETE FROM member_sessions WHERE member_id = $1 AND token_hash IS DISTINCT FROM $2::bytea +` + +type DeleteMemberSessionsParams struct { + MemberID string + Keep []byte +} + +// keep 为 NULL 时删除这个成员的全部会话。 +func (q *Queries) DeleteMemberSessions(ctx context.Context, arg DeleteMemberSessionsParams) error { + _, err := q.db.Exec(ctx, deleteMemberSessions, arg.MemberID, arg.Keep) + return err +} + +const deleteSession = `-- name: DeleteSession :exec +DELETE FROM member_sessions WHERE token_hash = $1 +` + +func (q *Queries) DeleteSession(ctx context.Context, tokenHash []byte) error { + _, err := q.db.Exec(ctx, deleteSession, tokenHash) + return err +} + const memberByToken = `-- name: MemberByToken :one -SELECT id, name, avatar_hash, created_at FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1) +SELECT id, name, avatar_hash, created_at, username, password_hash FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1) ` func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member, error) { @@ -52,16 +96,66 @@ func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member, &i.Name, &i.AvatarHash, &i.CreatedAt, + &i.Username, + &i.PasswordHash, ) return i, err } +const memberUsername = `-- name: MemberUsername :one +SELECT username FROM members WHERE id = $1 +` + +func (q *Queries) MemberUsername(ctx context.Context, id string) (*string, error) { + row := q.db.QueryRow(ctx, memberUsername, id) + var username *string + err := row.Scan(&username) + return username, err +} + +const registerMember = `-- name: RegisterMember :execrows +UPDATE members SET username = $1, password_hash = $2 +WHERE id = $3 AND username IS NULL +` + +type RegisterMemberParams struct { + Username *string + PasswordHash *string + ID string +} + +// 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。 +func (q *Queries) RegisterMember(ctx context.Context, arg RegisterMemberParams) (int64, error) { + result, err := q.db.Exec(ctx, registerMember, arg.Username, arg.PasswordHash, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const setMemberPassword = `-- name: SetMemberPassword :execrows +UPDATE members SET password_hash = $1 WHERE id = $2 AND username IS NOT NULL +` + +type SetMemberPasswordParams struct { + PasswordHash *string + ID string +} + +func (q *Queries) SetMemberPassword(ctx context.Context, arg SetMemberPasswordParams) (int64, error) { + result, err := q.db.Exec(ctx, setMemberPassword, arg.PasswordHash, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const updateMember = `-- name: UpdateMember :one UPDATE members SET name = coalesce($1, name), avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END WHERE id = $3 -RETURNING id, name, avatar_hash, created_at +RETURNING id, name, avatar_hash, created_at, username, password_hash ` type UpdateMemberParams struct { @@ -79,6 +173,8 @@ func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Mem &i.Name, &i.AvatarHash, &i.CreatedAt, + &i.Username, + &i.PasswordHash, ) return i, err } diff --git a/internal/adapter/out/postgres/internal/query/models.go b/internal/adapter/out/postgres/internal/query/models.go index 6756aa0..42fa126 100644 --- a/internal/adapter/out/postgres/internal/query/models.go +++ b/internal/adapter/out/postgres/internal/query/models.go @@ -9,10 +9,12 @@ import ( ) type Member struct { - ID string - Name string - AvatarHash *string - CreatedAt time.Time + ID string + Name string + AvatarHash *string + CreatedAt time.Time + Username *string + PasswordHash *string } type Room struct { diff --git a/internal/adapter/out/postgres/members.go b/internal/adapter/out/postgres/members.go index 7319456..4e4aeca 100644 --- a/internal/adapter/out/postgres/members.go +++ b/internal/adapter/out/postgres/members.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query" "github.com/dihemoe/alisten/internal/app/port" @@ -56,3 +57,90 @@ func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash *s func memberOf(row query.Member) member.Member { return member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)} } + +func (s *Store) Username(ctx context.Context, memberID string) (string, error) { + username, err := s.q.MemberUsername(ctx, memberID) + if errors.Is(err, pgx.ErrNoRows) { + err = port.ErrMemberNotFound + } + if err != nil { + return "", fmt.Errorf("读取成员 %s 的用户名:%w", memberID, err) + } + return orEmpty(username), nil +} + +func (s *Store) Register(ctx context.Context, memberID, username, passwordHash string) error { + n, err := s.q.RegisterMember(ctx, query.RegisterMemberParams{ID: memberID, Username: &username, PasswordHash: &passwordHash}) + if err != nil { + return fmt.Errorf("注册成员 %s:%w", memberID, usernameError(err)) + } + if n == 0 { + return fmt.Errorf("注册成员 %s:%w", memberID, port.ErrAlreadyRegistered) + } + return nil +} + +func usernameError(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "members_username_key" { + return port.ErrUsernameTaken + } + return err +} + +func (s *Store) AccountByUsername(ctx context.Context, username string) (port.Account, error) { + row, err := s.q.AccountByUsername(ctx, &username) + if errors.Is(err, pgx.ErrNoRows) { + err = port.ErrMemberNotFound + } + if err != nil { + return port.Account{}, fmt.Errorf("按用户名查找成员:%w", err) + } + return port.Account{Member: memberOf(row), Username: orEmpty(row.Username), PasswordHash: orEmpty(row.PasswordHash)}, nil +} + +func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte) error { + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.q.WithTx(tx) + if err := q.DeleteSession(ctx, oldTokenHash); err != nil { + return err + } + return q.CreateSession(ctx, query.CreateSessionParams{TokenHash: newTokenHash, MemberID: memberID}) + }) + if err != nil { + return fmt.Errorf("给成员 %s 更换会话:%w", memberID, err) + } + return nil +} + +func (s *Store) DeleteSession(ctx context.Context, tokenHash []byte) error { + if err := s.q.DeleteSession(ctx, tokenHash); err != nil { + return fmt.Errorf("删除会话:%w", err) + } + return nil +} + +func (s *Store) DeleteSessions(ctx context.Context, memberID string, keepTokenHash []byte) error { + if err := s.q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash}); err != nil { + return fmt.Errorf("删除成员 %s 的会话:%w", memberID, err) + } + return nil +} + +func (s *Store) SetPassword(ctx context.Context, memberID, passwordHash string, keepTokenHash []byte) error { + 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 + } + return q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash}) + }) + if err != nil { + return fmt.Errorf("修改成员 %s 的密码:%w", memberID, err) + } + return nil +} diff --git a/internal/adapter/out/postgres/members_test.go b/internal/adapter/out/postgres/members_test.go index a6f2b64..217fee3 100644 --- a/internal/adapter/out/postgres/members_test.go +++ b/internal/adapter/out/postgres/members_test.go @@ -100,3 +100,107 @@ func TestUpdateMemberKeepsOmittedFields(t *testing.T) { t.Errorf("修改不存在的成员,错误 = %v,期望 ErrMemberNotFound", err) } } + +func ownerOf(t *testing.T, store *Store, token string) string { + t.Helper() + m, err := store.MemberByToken(t.Context(), tokenHash(token)) + if errors.Is(err, port.ErrMemberNotFound) { + return "" + } + if err != nil { + t.Fatal(err) + } + return m.ID +} + +func TestRegisterMember(t *testing.T) { + store, _ := newStore(t) + alice := createMember(t, store, "alice", "游客-1a2b") + bob := createMember(t, store, "bob", "游客-3c4d") + if username, err := store.Username(t.Context(), alice.ID); err != nil || username != "" { + t.Fatalf("游客的用户名 = %q, %v,期望空字符串", username, err) + } + if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); err != nil { + t.Fatal(err) + } + if username, err := store.Username(t.Context(), alice.ID); err != nil || username != "alice" { + t.Errorf("注册之后的用户名 = %q, %v,期望 alice", username, err) + } + account, err := store.AccountByUsername(t.Context(), "alice") + if want := (port.Account{Member: alice, Username: "alice", PasswordHash: "hash-a"}); err != nil || account != want { + t.Errorf("AccountByUsername = %#v, %v,期望 %#v", account, err, want) + } + if err := store.Register(t.Context(), bob.ID, "alice", "hash-b"); !errors.Is(err, port.ErrUsernameTaken) { + t.Errorf("用户名重复时 = %v,期望 ErrUsernameTaken", err) + } + if err := store.Register(t.Context(), alice.ID, "alice2", "hash-c"); !errors.Is(err, port.ErrAlreadyRegistered) { + t.Errorf("已经注册过的成员再注册 = %v,期望 ErrAlreadyRegistered", err) + } + if _, err := store.AccountByUsername(t.Context(), "nobody"); !errors.Is(err, port.ErrMemberNotFound) { + t.Errorf("不存在的用户名 = %v,期望 ErrMemberNotFound", err) + } + if _, err := store.Username(t.Context(), "00000000-0000-7000-8000-000000000000"); !errors.Is(err, port.ErrMemberNotFound) { + t.Errorf("不存在的成员 = %v,期望 ErrMemberNotFound", err) + } +} + +func TestMemberSessions(t *testing.T) { + store, _ := newStore(t) + alice := createMember(t, store, "alice-1", "游客-1a2b") + createMember(t, store, "guest-1", "游客-3c4d") + createMember(t, store, "guest-2", "游客-5e6f") + for _, s := range []struct{ guest, token string }{{"guest-1", "alice-2"}, {"guest-2", "alice-3"}} { + if err := store.ReplaceSession(t.Context(), tokenHash(s.guest), alice.ID, tokenHash(s.token)); err != nil { + t.Fatal(err) + } + if got := ownerOf(t, store, s.guest); got != "" { + t.Errorf("换掉之后 %s 仍属于成员 %s", s.guest, got) + } + if got := ownerOf(t, store, s.token); got != alice.ID { + t.Errorf("%s 属于成员 %q,期望 %s", s.token, got, alice.ID) + } + } + if err := store.DeleteSessions(t.Context(), alice.ID, tokenHash("alice-1")); err != nil { + t.Fatal(err) + } + if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "alice-3") != "" { + t.Error("DeleteSessions 应只留下指定的会话") + } + if err := store.DeleteSession(t.Context(), tokenHash("alice-1")); err != nil { + t.Fatal(err) + } + if got := ownerOf(t, store, "alice-1"); got != "" { + t.Errorf("删除之后 alice-1 仍属于成员 %s", got) + } +} + +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) { + 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) + } + createMember(t, store, "other", "游客-5e6f") + if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-2")); err != nil { + t.Fatal(err) + } + if err := store.SetPassword(t.Context(), alice.ID, "new", tokenHash("alice-1")); err != nil { + t.Fatal(err) + } + if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "new" { + t.Errorf("修改之后的密码哈希 = %q, %v,期望 new", account.PasswordHash, err) + } + if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != "" { + t.Error("修改密码应只留下指定的会话") + } + if err := store.SetPassword(t.Context(), alice.ID, "newer", nil); err != nil { + t.Fatal(err) + } + if got := ownerOf(t, store, "alice-1"); got != "" { + t.Errorf("不指定保留的会话时应删除全部会话,alice-1 仍属于 %s", got) + } +} diff --git a/internal/app/port/member.go b/internal/app/port/member.go index 00c3a9d..db3a081 100644 --- a/internal/app/port/member.go +++ b/internal/app/port/member.go @@ -7,12 +7,36 @@ import ( "github.com/dihemoe/alisten/internal/domain/member" ) -var ErrMemberNotFound = errors.New("成员不存在") +var ( + ErrMemberNotFound = errors.New("成员不存在") + ErrUsernameTaken = errors.New("用户名已被占用") + ErrAlreadyRegistered = errors.New("已经注册过了") +) + +// 设置了用户名的成员。 +type Account struct { + Member member.Member + Username string + PasswordHash string +} // 成员 token 只以 SHA-256 哈希的形式出现在端口中。一个成员 token 是成员的一个会话,同一个成员可以有多个会话。 type Members interface { + // 创建游客和它的第一个会话。 CreateMember(ctx context.Context, tokenHash []byte, name string) (member.Member, error) MemberByToken(ctx context.Context, tokenHash []byte) (member.Member, error) // name 或 avatarHash 为 nil 时保持原值。 UpdateMember(ctx context.Context, id string, name, avatarHash *string) (member.Member, error) + // 游客的用户名是空字符串。 + Username(ctx context.Context, memberID string) (string, error) + // 用户名已属于其他成员时返回 ErrUsernameTaken,成员已经注册过时返回 ErrAlreadyRegistered。 + Register(ctx context.Context, memberID, username, passwordHash string) error + AccountByUsername(ctx context.Context, username string) (Account, error) + // 在同一个事务中删除 oldTokenHash 的会话,给 memberID 创建 newTokenHash 的会话。 + ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte) error + DeleteSession(ctx context.Context, tokenHash []byte) error + // 删除成员除 keepTokenHash 以外的会话,keepTokenHash 为 nil 时全部删除。 + DeleteSessions(ctx context.Context, memberID string, keepTokenHash []byte) error + // 在同一个事务中修改已注册成员的密码哈希,并像 DeleteSessions 一样删除会话。成员是游客时返回 ErrMemberNotFound。 + SetPassword(ctx context.Context, memberID, passwordHash string, keepTokenHash []byte) error } diff --git a/internal/app/port/porttest/store.go b/internal/app/port/porttest/store.go index 1279249..630df77 100644 --- a/internal/app/port/porttest/store.go +++ b/internal/app/port/porttest/store.go @@ -35,7 +35,9 @@ type Store struct { } type storedMember struct { - member member.Member + member member.Member + username string + passwordHash string } type storedTicket struct { @@ -136,6 +138,91 @@ func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash *str return member.Member{}, port.ErrMemberNotFound } +func (s *Store) Username(_ context.Context, memberID string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + m := s.member(memberID) + if m == nil { + return "", port.ErrMemberNotFound + } + return m.username, nil +} + +func (s *Store) Register(_ context.Context, memberID, username, passwordHash string) error { + if err := checkText(username, passwordHash); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + m := s.member(memberID) + if m == nil || m.username != "" { + return port.ErrAlreadyRegistered + } + if slices.ContainsFunc(s.members, func(other storedMember) bool { return other.username == username }) { + return port.ErrUsernameTaken + } + m.username, m.passwordHash = username, passwordHash + return nil +} + +func (s *Store) AccountByUsername(_ context.Context, username string) (port.Account, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, m := range s.members { + if username != "" && m.username == username { + return port.Account{Member: m.member, Username: m.username, PasswordHash: m.passwordHash}, nil + } + } + return port.Account{}, port.ErrMemberNotFound +} + +func (s *Store) ReplaceSession(_ context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.member(memberID) == nil { + return fmt.Errorf("成员 %s 不存在", memberID) + } + if _, ok := s.sessions[string(newTokenHash)]; ok { + return errors.New("token 哈希重复") + } + delete(s.sessions, string(oldTokenHash)) + s.sessions[string(newTokenHash)] = memberID + return nil +} + +func (s *Store) DeleteSession(_ context.Context, tokenHash []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, string(tokenHash)) + return nil +} + +func (s *Store) DeleteSessions(_ context.Context, memberID string, keepTokenHash []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + s.deleteSessions(memberID, keepTokenHash) + return nil +} + +func (s *Store) SetPassword(_ context.Context, memberID, passwordHash string, keepTokenHash []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + m := s.member(memberID) + if m == nil || m.username == "" { + return port.ErrMemberNotFound + } + m.passwordHash = passwordHash + s.deleteSessions(memberID, keepTokenHash) + return nil +} + +// 调用方持有 s.mu。 +func (s *Store) deleteSessions(memberID string, keepTokenHash []byte) { + maps.DeleteFunc(s.sessions, func(hash, id string) bool { + return id == memberID && (keepTokenHash == nil || hash != string(keepTokenHash)) + }) +} + func (s *Store) CreateTicket(_ context.Context, t port.NewTicket) error { s.mu.Lock() defer s.mu.Unlock()