From 1277b3ac8556061abdf3651f14cb61917ee029b5 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/00003_member_accounts.sql | 25 ++ db/queries/members.sql | 37 ++- internal/adapter/in/api/api_test.go | 2 +- internal/adapter/out/postgres/apikeys.go | 2 +- internal/adapter/out/postgres/apikeys_test.go | 15 +- .../postgres/internal/query/members.sql.go | 171 ++++++++++-- .../out/postgres/internal/query/models.go | 12 +- internal/adapter/out/postgres/members.go | 109 +++++++- internal/adapter/out/postgres/members_test.go | 246 +++++++++++++++++- internal/adapter/out/postgres/migrate_test.go | 51 ++++ internal/adapter/out/postgres/rooms_test.go | 2 +- internal/adapter/out/postgres/tickets_test.go | 2 +- internal/app/identity/identity.go | 6 +- internal/app/port/apikey.go | 2 - internal/app/port/member.go | 41 ++- internal/app/port/porttest/store.go | 148 +++++++++-- internal/app/port/porttest/store_test.go | 15 ++ 17 files changed, 813 insertions(+), 73 deletions(-) create mode 100644 db/migrations/00003_member_accounts.sql create mode 100644 internal/app/port/porttest/store_test.go diff --git a/db/migrations/00003_member_accounts.sql b/db/migrations/00003_member_accounts.sql new file mode 100644 index 0000000..11a87e3 --- /dev/null +++ b/db/migrations/00003_member_accounts.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- 一个会话对应一个成员 token,同一个成员可以在多台设备上各有一个会话。 +CREATE TABLE member_sessions ( + token_hash bytea PRIMARY KEY, + member_id uuid NOT NULL REFERENCES members ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ON member_sessions (member_id); + +-- bot 成员只通过 API Key 使用 HTTP 接口,不需要会话。 +INSERT INTO member_sessions (token_hash, member_id, created_at) +SELECT m.token_hash, m.id, m.created_at FROM members m +WHERE NOT EXISTS (SELECT FROM rooms r WHERE r.bot_member_id = m.id); + +ALTER TABLE members DROP COLUMN token_hash; + +-- 设置了用户名的成员是已注册的账号,否则是游客。用户名只能由小写字母、数字、下划线和连字符组成,所以按原文唯一即可。 +-- 邮箱属于账号,在所有设备上一致;游客没有账号,不替匿名身份保存个人信息,邮箱只存在游客自己的浏览器里。 +ALTER TABLE members + ADD COLUMN username text UNIQUE, + ADD COLUMN password_hash text, + ADD CHECK (password_hash IS NULL OR username IS NOT NULL), + ADD COLUMN email text, + ADD CHECK (email IS NULL OR username IS NOT NULL); diff --git a/db/queries/members.sql b/db/queries/members.sql index 64afd3a..30ad173 100644 --- a/db/queries/members.sql +++ b/db/queries/members.sql @@ -1,14 +1,43 @@ -- name: CreateMember :one -INSERT INTO members (token_hash, name) VALUES (@token_hash, @name) +INSERT INTO members (name) VALUES (@name) RETURNING *; +-- name: CreateSession :exec +INSERT INTO member_sessions (token_hash, member_id) VALUES (@token_hash, @member_id); + -- name: MemberByToken :one -SELECT * FROM members WHERE token_hash = @token_hash; +SELECT * FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = @token_hash); -- name: UpdateMember :one --- 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。 +-- 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希和邮箱传入空串表示清除。游客的邮箱总是 NULL。 UPDATE members 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 + avatar_hash = CASE WHEN sqlc.narg(avatar_hash)::text IS NULL THEN avatar_hash ELSE NULLIF(sqlc.narg(avatar_hash)::text, '') END, + email = CASE WHEN username IS NULL THEN NULL WHEN sqlc.narg(email)::text IS NULL THEN email ELSE NULLIF(sqlc.narg(email)::text, '') END WHERE id = @id RETURNING *; + +-- name: MemberAccountProfile :one +SELECT username, email 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: LockMemberPassword :one +-- FOR SHARE 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。 +SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id FOR SHARE; + +-- name: DeleteSession :execrows +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/in/api/api_test.go b/internal/adapter/in/api/api_test.go index 6c7d8d9..0bdd6a8 100644 --- a/internal/adapter/in/api/api_test.go +++ b/internal/adapter/in/api/api_test.go @@ -298,7 +298,7 @@ func TestAbortHandlerIsLeftToNetHTTP(t *testing.T) { type brokenProfiles struct{ *porttest.Store } -func (brokenProfiles) UpdateMember(context.Context, string, *string, *string) (member.Member, error) { +func (brokenProfiles) UpdateMember(context.Context, string, *string, *string, *string) (member.Member, error) { return member.Member{}, errors.New("连接中断") } diff --git a/internal/adapter/out/postgres/apikeys.go b/internal/adapter/out/postgres/apikeys.go index ebb9ff1..21bd798 100644 --- a/internal/adapter/out/postgres/apikeys.go +++ b/internal/adapter/out/postgres/apikeys.go @@ -41,7 +41,7 @@ func (s *Store) ResetRoomAPIKey(ctx context.Context, roomID string, k port.NewAP return err } } else { - m, err := q.CreateMember(ctx, query.CreateMemberParams{TokenHash: k.BotTokenHash, Name: botName}) + m, err := q.CreateMember(ctx, botName) if err != nil { return err } diff --git a/internal/adapter/out/postgres/apikeys_test.go b/internal/adapter/out/postgres/apikeys_test.go index 98c3c28..e593704 100644 --- a/internal/adapter/out/postgres/apikeys_test.go +++ b/internal/adapter/out/postgres/apikeys_test.go @@ -12,7 +12,7 @@ import ( ) func newAPIKey(key string) port.NewAPIKey { - return port.NewAPIKey{TokenHash: tokenHash(key), BotTokenHash: tokenHash("bot " + key)} + return port.NewAPIKey{TokenHash: tokenHash(key)} } func resetAPIKey(t *testing.T, store *Store, roomID, key string) { @@ -150,3 +150,16 @@ func TestConcurrentFirstResetsCreateOneBot(t *testing.T) { t.Errorf("有 %d 把 Key、%d 个 bot 成员,期望各一个", n, bots) } } + +func TestRoomBotHasNoSession(t *testing.T) { + store, pool := newStore(t) + id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"}) + resetAPIKey(t, store, id, "key-1") + var sessions int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM member_sessions s JOIN rooms r ON r.bot_member_id = s.member_id WHERE r.id = $1", id).Scan(&sessions); err != nil { + t.Fatal(err) + } + if sessions != 0 { + t.Errorf("bot 成员有 %d 个会话,期望没有:bot 只通过 API Key 使用 HTTP 接口", sessions) + } +} diff --git a/internal/adapter/out/postgres/internal/query/members.sql.go b/internal/adapter/out/postgres/internal/query/members.sql.go index 0ced928..0a78295 100644 --- a/internal/adapter/out/postgres/internal/query/members.sql.go +++ b/internal/adapter/out/postgres/internal/query/members.sql.go @@ -9,31 +9,116 @@ import ( "context" ) -const createMember = `-- name: CreateMember :one -INSERT INTO members (token_hash, name) VALUES ($1, $2) -RETURNING id, token_hash, name, avatar_hash, created_at +const accountByUsername = `-- name: AccountByUsername :one +SELECT id, name, avatar_hash, created_at, username, password_hash, email FROM members WHERE username = $1 ` -type CreateMemberParams struct { - TokenHash []byte - Name string -} - -func (q *Queries) CreateMember(ctx context.Context, arg CreateMemberParams) (Member, error) { - row := q.db.QueryRow(ctx, createMember, arg.TokenHash, arg.Name) +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.TokenHash, &i.Name, &i.AvatarHash, &i.CreatedAt, + &i.Username, + &i.PasswordHash, + &i.Email, ) return i, err } +const createMember = `-- name: CreateMember :one +INSERT INTO members (name) VALUES ($1) +RETURNING id, name, avatar_hash, created_at, username, password_hash, email +` + +func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) { + row := q.db.QueryRow(ctx, createMember, name) + var i Member + err := row.Scan( + &i.ID, + &i.Name, + &i.AvatarHash, + &i.CreatedAt, + &i.Username, + &i.PasswordHash, + &i.Email, + ) + return i, err +} + +const createSession = `-- name: CreateSession :exec +INSERT INTO member_sessions (token_hash, member_id) VALUES ($1, $2) +` + +type CreateSessionParams struct { + TokenHash []byte + MemberID string +} + +func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error { + _, err := q.db.Exec(ctx, createSession, arg.TokenHash, arg.MemberID) + 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 :execrows +DELETE FROM member_sessions WHERE token_hash = $1 +` + +func (q *Queries) DeleteSession(ctx context.Context, tokenHash []byte) (int64, error) { + result, err := q.db.Exec(ctx, deleteSession, tokenHash) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const lockMemberPassword = `-- name: LockMemberPassword :one +SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 FOR SHARE +` + +// FOR SHARE 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。 +func (q *Queries) LockMemberPassword(ctx context.Context, id string) (string, error) { + row := q.db.QueryRow(ctx, lockMemberPassword, id) + var password_hash string + err := row.Scan(&password_hash) + return password_hash, err +} + +const memberAccountProfile = `-- name: MemberAccountProfile :one +SELECT username, email FROM members WHERE id = $1 +` + +type MemberAccountProfileRow struct { + Username *string + Email *string +} + +func (q *Queries) MemberAccountProfile(ctx context.Context, id string) (MemberAccountProfileRow, error) { + row := q.db.QueryRow(ctx, memberAccountProfile, id) + var i MemberAccountProfileRow + err := row.Scan(&i.Username, &i.Email) + return i, err +} + const memberByToken = `-- name: MemberByToken :one -SELECT id, token_hash, name, avatar_hash, created_at FROM members WHERE token_hash = $1 +SELECT id, name, avatar_hash, created_at, username, password_hash, email 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) { @@ -41,38 +126,86 @@ func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member, var i Member err := row.Scan( &i.ID, - &i.TokenHash, &i.Name, &i.AvatarHash, &i.CreatedAt, + &i.Username, + &i.PasswordHash, + &i.Email, ) return i, 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, token_hash, name, avatar_hash, created_at + avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END, + email = CASE WHEN username IS NULL THEN NULL WHEN $3::text IS NULL THEN email ELSE NULLIF($3::text, '') END +WHERE id = $4 +RETURNING id, name, avatar_hash, created_at, username, password_hash, email ` type UpdateMemberParams struct { Name *string AvatarHash *string + Email *string ID string } -// 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。 +// 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希和邮箱传入空串表示清除。游客的邮箱总是 NULL。 func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Member, error) { - row := q.db.QueryRow(ctx, updateMember, arg.Name, arg.AvatarHash, arg.ID) + row := q.db.QueryRow(ctx, updateMember, + arg.Name, + arg.AvatarHash, + arg.Email, + arg.ID, + ) var i Member err := row.Scan( &i.ID, - &i.TokenHash, &i.Name, &i.AvatarHash, &i.CreatedAt, + &i.Username, + &i.PasswordHash, + &i.Email, ) return i, err } diff --git a/internal/adapter/out/postgres/internal/query/models.go b/internal/adapter/out/postgres/internal/query/models.go index 2cb8ec7..3561555 100644 --- a/internal/adapter/out/postgres/internal/query/models.go +++ b/internal/adapter/out/postgres/internal/query/models.go @@ -9,11 +9,13 @@ import ( ) type Member struct { - ID string - TokenHash []byte - Name string - AvatarHash *string - CreatedAt time.Time + ID string + Name string + AvatarHash *string + CreatedAt time.Time + Username *string + PasswordHash *string + Email *string } type Room struct { diff --git a/internal/adapter/out/postgres/members.go b/internal/adapter/out/postgres/members.go index a90ce23..c88517a 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" @@ -15,11 +16,20 @@ import ( var _ port.Members = (*Store)(nil) func (s *Store) CreateMember(ctx context.Context, tokenHash []byte, name string) (member.Member, error) { - row, err := s.q.CreateMember(ctx, query.CreateMemberParams{TokenHash: tokenHash, Name: name}) + var m member.Member + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.q.WithTx(tx) + row, err := q.CreateMember(ctx, name) + if err != nil { + return err + } + m = memberOf(row) + return q.CreateSession(ctx, query.CreateSessionParams{TokenHash: tokenHash, MemberID: row.ID}) + }) if err != nil { return member.Member{}, fmt.Errorf("创建成员:%w", err) } - return memberOf(row), nil + return m, nil } func (s *Store) MemberByToken(ctx context.Context, tokenHash []byte) (member.Member, error) { @@ -33,8 +43,8 @@ func (s *Store) MemberByToken(ctx context.Context, tokenHash []byte) (member.Mem return memberOf(row), nil } -func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash *string) (member.Member, error) { - row, err := s.q.UpdateMember(ctx, query.UpdateMemberParams{ID: id, Name: name, AvatarHash: avatarHash}) +func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash, email *string) (member.Member, error) { + row, err := s.q.UpdateMember(ctx, query.UpdateMemberParams{ID: id, Name: name, AvatarHash: avatarHash, Email: email}) if errors.Is(err, pgx.ErrNoRows) { err = port.ErrMemberNotFound } @@ -47,3 +57,94 @@ 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) AccountProfile(ctx context.Context, memberID string) (port.AccountProfile, error) { + row, err := s.q.MemberAccountProfile(ctx, memberID) + if errors.Is(err, pgx.ErrNoRows) { + err = port.ErrMemberNotFound + } + if err != nil { + return port.AccountProfile{}, fmt.Errorf("读取成员 %s 的账号资料:%w", memberID, err) + } + return port.AccountProfile{Username: orEmpty(row.Username), Email: orEmpty(row.Email)}, 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), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}, nil +} + +func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error { + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.q.WithTx(tx) + current, err := q.LockMemberPassword(ctx, memberID) + if err != nil { + return err + } + if current != passwordHash { + return port.ErrAccountPasswordChanged + } + n, err := q.DeleteSession(ctx, oldTokenHash) + if err != nil { + return err + } + if n == 0 { + return port.ErrSessionNotFound + } + 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) 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..5683576 100644 --- a/internal/adapter/out/postgres/members_test.go +++ b/internal/adapter/out/postgres/members_test.go @@ -59,13 +59,13 @@ func TestMemberAvatarIsNullWhenUnset(t *testing.T) { if hash := avatar(); hash != "NULL" { t.Errorf("新成员的 avatar_hash = %q,期望 NULL", hash) } - if _, err := store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346")); err != nil { + if _, err := store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"), nil); err != nil { t.Fatal(err) } if hash := avatar(); hash != "0bc83cb571cd1c50ba6f3e8a78ef1346" { t.Errorf("设置头像之后 avatar_hash = %q", hash) } - got, err := store.UpdateMember(t.Context(), m.ID, nil, ptr("")) + got, err := store.UpdateMember(t.Context(), m.ID, nil, ptr(""), nil) if err != nil { t.Fatal(err) } @@ -78,14 +78,14 @@ func TestUpdateMemberKeepsOmittedFields(t *testing.T) { store, _ := newStore(t) m := createMember(t, store, "alice", "游客-1a2b") - got, err := store.UpdateMember(t.Context(), m.ID, ptr("甲"), nil) + got, err := store.UpdateMember(t.Context(), m.ID, ptr("甲"), nil, nil) if err != nil { t.Fatal(err) } if want := (member.Member{ID: m.ID, Name: "甲"}); got != want { t.Errorf("只改昵称 = %#v,期望 %#v", got, want) } - got, err = store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346")) + got, err = store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"), nil) if err != nil { t.Fatal(err) } @@ -96,7 +96,243 @@ func TestUpdateMemberKeepsOmittedFields(t *testing.T) { t.Errorf("修改后按 token 查到 %#v,期望 %#v", found, got) } - if _, err := store.UpdateMember(t.Context(), "01a15778-1911-7a28-8b2e-e08c2a25f388", ptr("乙"), nil); !errors.Is(err, port.ErrMemberNotFound) { + if _, err := store.UpdateMember(t.Context(), "01a15778-1911-7a28-8b2e-e08c2a25f388", ptr("乙"), nil, nil); !errors.Is(err, port.ErrMemberNotFound) { t.Errorf("修改不存在的成员,错误 = %v,期望 ErrMemberNotFound", err) } } + +func TestUpdateMemberEmail(t *testing.T) { + store, pool := newStore(t) + alice := createMember(t, store, "alice", "游客-1a2b") + guest := createMember(t, store, "guest", "游客-3c4d") + if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); err != nil { + t.Fatal(err) + } + column := func(id string) string { + t.Helper() + var email string + if err := pool.QueryRow(t.Context(), "SELECT coalesce(email, 'NULL') FROM members WHERE id = $1", id).Scan(&email); err != nil { + t.Fatal(err) + } + return email + } + profile := func() port.AccountProfile { + t.Helper() + p, err := store.AccountProfile(t.Context(), alice.ID) + if err != nil { + t.Fatal(err) + } + return p + } + + if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("Alice@Example.COM")); err != nil { + t.Fatal(err) + } + if got := profile(); got != (port.AccountProfile{Username: "alice", Email: "Alice@Example.COM"}) { + t.Errorf("保存邮箱之后 AccountProfile = %#v", got) + } + if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.Email != "Alice@Example.COM" { + t.Errorf("AccountByUsername 的邮箱 = %q, %v", account.Email, err) + } + if _, err := store.UpdateMember(t.Context(), alice.ID, ptr("甲"), nil, nil); err != nil { + t.Fatal(err) + } + if got := profile().Email; got != "Alice@Example.COM" { + t.Errorf("邮箱参数为 nil 时应保持原值,得到 %q", got) + } + if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("b@example.com")); err != nil { + t.Fatal(err) + } + if got := profile().Email; got != "b@example.com" { + t.Errorf("更新邮箱之后 = %q", got) + } + if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("")); err != nil { + t.Fatal(err) + } + if got := column(alice.ID); got != "NULL" { + t.Errorf("清除邮箱之后 email 列 = %q,期望 NULL", got) + } + + if _, err := store.UpdateMember(t.Context(), guest.ID, nil, nil, ptr("g@example.com")); err != nil { + t.Fatal(err) + } + if got := column(guest.ID); got != "NULL" { + t.Errorf("游客传入邮箱之后 email 列 = %q,期望 NULL", got) + } + if p, err := store.AccountProfile(t.Context(), guest.ID); err != nil || p != (port.AccountProfile{}) { + t.Errorf("游客的 AccountProfile = %#v, %v", p, 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 profile, err := store.AccountProfile(t.Context(), alice.ID); err != nil || profile != (port.AccountProfile{}) { + t.Fatalf("游客的 AccountProfile = %#v, %v,期望两个字段都为空", profile, err) + } + if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); err != nil { + t.Fatal(err) + } + if profile, err := store.AccountProfile(t.Context(), alice.ID); err != nil || profile != (port.AccountProfile{Username: "alice"}) { + t.Errorf("注册之后的 AccountProfile = %#v, %v,期望只有用户名 alice", profile, 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.AccountProfile(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.Register(t.Context(), alice.ID, "alice", "old"); err != nil { + t.Fatal(err) + } + if err := store.SetPassword(t.Context(), alice.ID, "new", 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("SetPassword 应只留下指定的会话") + } + 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"), "old"); 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) + } +} + +func TestReplaceSessionChecksGuestSessionAndPassword(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") + + if err := store.ReplaceSession(t.Context(), tokenHash("gone"), alice.ID, tokenHash("alice-2"), "h1"); !errors.Is(err, port.ErrSessionNotFound) { + t.Errorf("游客的会话已经删除时 = %v,期望 ErrSessionNotFound", err) + } + if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h0"); !errors.Is(err, port.ErrAccountPasswordChanged) { + t.Errorf("密码哈希已经变了时 = %v,期望 ErrAccountPasswordChanged", err) + } + if ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "guest") == "" { + t.Error("失败时不应创建新会话,也不应删除游客的会话") + } + if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h1"); err != nil { + t.Fatal(err) + } + if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-3"), "h1"); !errors.Is(err, port.ErrSessionNotFound) { + t.Errorf("同一个游客会话再登录一次 = %v,期望 ErrSessionNotFound", err) + } + if ownerOf(t, store, "alice-3") != "" { + t.Error("同一个游客会话只能换出一个新会话") + } +} + +func TestReplaceSessionWaitsForPasswordChange(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) + } + createMember(t, store, "guest", "游客-3c4d") + + // 模拟 SetPassword 的事务,先不提交。 + 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", alice.ID); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE member_id = $1", alice.ID); err != nil { + t.Fatal(err) + } + + result := make(chan error, 1) + go func() { + result <- store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h1") + }() + waitForLockWait(t, pool, 1) + if err := tx.Commit(t.Context()); err != nil { + t.Fatal(err) + } + + if err := <-result; !errors.Is(err, port.ErrAccountPasswordChanged) { + t.Errorf("改密码提交之后,登录的错误 = %v,期望 ErrAccountPasswordChanged", err) + } + if got := ownerOf(t, store, "alice-2"); got != "" { + t.Errorf("改密码之后还留下了按旧密码签发的会话,属于 %s", got) + } +} diff --git a/internal/adapter/out/postgres/migrate_test.go b/internal/adapter/out/postgres/migrate_test.go index 21bff2a..5f0c829 100644 --- a/internal/adapter/out/postgres/migrate_test.go +++ b/internal/adapter/out/postgres/migrate_test.go @@ -11,6 +11,11 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + + "github.com/dihemoe/alisten/db/migrations" + "github.com/dihemoe/alisten/internal/app/port" ) func migrate(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) error { @@ -128,3 +133,49 @@ func TestMigrationLogsKeepFixedFields(t *testing.T) { } } } + +func TestMemberSessionsMigrationKeepsMemberTokens(t *testing.T) { + ctx := t.Context() + name := fmt.Sprintf("test_%d", databases.Add(1)) + if _, err := admin.Exec(ctx, "CREATE DATABASE "+name); err != nil { + t.Fatal(err) + } + cfg := server.Copy() + cfg.ConnConfig.Database = name + pool := connect(t, cfg) + db := stdlib.OpenDBFromPool(pool) + t.Cleanup(func() { _ = db.Close() }) + provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations.FS) + if err != nil { + t.Fatal(err) + } + if _, err := provider.UpTo(ctx, 2); err != nil { + t.Fatal(err) + } + created := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + var guest, bot string + if err := pool.QueryRow(ctx, "INSERT INTO members (token_hash, name, created_at) VALUES ($1, '游客-1a2b', $2) RETURNING id", tokenHash("guest"), created).Scan(&guest); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, "INSERT INTO members (token_hash, name) VALUES ($1, 'bot') RETURNING id", tokenHash("bot")).Scan(&bot); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, "INSERT INTO rooms (kind, name, description, bot_member_id) VALUES ('persistent', '大厅', '', $1)", bot); err != nil { + t.Fatal(err) + } + if _, err := provider.Up(ctx); err != nil { + t.Fatal(err) + } + + store := New(pool) + if m, err := store.MemberByToken(ctx, tokenHash("guest")); err != nil || m.ID != guest { + t.Errorf("迁移之后按原来的 token 找到 %#v, %v,期望成员 %s", m, err, guest) + } + var sessionCreated time.Time + if err := pool.QueryRow(ctx, "SELECT created_at FROM member_sessions WHERE member_id = $1", guest).Scan(&sessionCreated); err != nil || !sessionCreated.Equal(created) { + t.Errorf("会话的创建时间 = %v, %v,期望沿用成员的创建时间 %v", sessionCreated, err, created) + } + if _, err := store.MemberByToken(ctx, tokenHash("bot")); !errors.Is(err, port.ErrMemberNotFound) { + t.Errorf("bot 成员原来的 token 在迁移之后 = %v,期望不再有效", err) + } +} diff --git a/internal/adapter/out/postgres/rooms_test.go b/internal/adapter/out/postgres/rooms_test.go index 7314ec8..1f328b1 100644 --- a/internal/adapter/out/postgres/rooms_test.go +++ b/internal/adapter/out/postgres/rooms_test.go @@ -25,7 +25,7 @@ func addTicket(t *testing.T, pool *pgxpool.Pool, roomID string) { t.Helper() _, err := pool.Exec(t.Context(), ` WITH m AS ( - INSERT INTO members (token_hash, name) VALUES (sha256(gen_random_uuid()::text::bytea), '甲') + INSERT INTO members (name) VALUES ('甲') RETURNING id ) INSERT INTO room_tickets (token_hash, room_id, member_id) SELECT sha256(gen_random_uuid()::text::bytea), $1, id FROM m`, diff --git a/internal/adapter/out/postgres/tickets_test.go b/internal/adapter/out/postgres/tickets_test.go index 701bc2b..a6a50b2 100644 --- a/internal/adapter/out/postgres/tickets_test.go +++ b/internal/adapter/out/postgres/tickets_test.go @@ -24,7 +24,7 @@ func TestTicketHolder(t *testing.T) { t.Errorf("TicketHolder = %#v, %v,期望 %#v", got, err, want) } - renamed, err := store.UpdateMember(t.Context(), alice.ID, ptr("新名字"), nil) + renamed, err := store.UpdateMember(t.Context(), alice.ID, ptr("新名字"), nil, nil) if err != nil { t.Fatal(err) } diff --git a/internal/app/identity/identity.go b/internal/app/identity/identity.go index dcddbfc..f73dc0e 100644 --- a/internal/app/identity/identity.go +++ b/internal/app/identity/identity.go @@ -74,7 +74,7 @@ func (s *Service) UpdateProfile(ctx context.Context, memberID string, p Profile) hash := avatarHash(email) avatar = &hash } - m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar) + m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar, nil) if err != nil { return member.Member{}, err } @@ -140,9 +140,7 @@ func (s *Service) EnterRoom(ctx context.Context, roomID, ticket string) (*realti return s.gateway.Join(logctx.WithMember(logctx.WithRoom(ctx, r.Info.ID), holder.Member.ID), r.Info.ID, holder.Member) } -// 每次都准备好 bot 成员的 token:房间还没有 bot 成员时由存储用它创建,这个随机值不交给任何人。 func NewAPIKey() (string, port.NewAPIKey) { key, hash := newSecret() - _, botHash := newSecret() - return key, port.NewAPIKey{TokenHash: hash, BotTokenHash: botHash} + return key, port.NewAPIKey{TokenHash: hash} } diff --git a/internal/app/port/apikey.go b/internal/app/port/apikey.go index 0d4535c..da8f825 100644 --- a/internal/app/port/apikey.go +++ b/internal/app/port/apikey.go @@ -8,8 +8,6 @@ import ( // 常驻房间的 API Key 是房间 bot 成员的票据。 type NewAPIKey struct { TokenHash []byte - // 房间还没有 bot 成员时用它创建:对应的随机值不交给任何人,bot 因此没有可用的成员 token。 - BotTokenHash []byte } type APIKeyStatus struct { diff --git a/internal/app/port/member.go b/internal/app/port/member.go index 18bba50..d61dddf 100644 --- a/internal/app/port/member.go +++ b/internal/app/port/member.go @@ -7,12 +7,45 @@ import ( "github.com/dihemoe/alisten/internal/domain/member" ) -var ErrMemberNotFound = errors.New("成员不存在") +var ( + ErrMemberNotFound = errors.New("成员不存在") + ErrUsernameTaken = errors.New("用户名已被占用") + ErrAlreadyRegistered = errors.New("已经注册过了") + ErrSessionNotFound = errors.New("会话不存在") + ErrAccountPasswordChanged = errors.New("账号密码已修改") +) -// 成员 token 只以 SHA-256 哈希的形式出现在端口中。 +// 设置了用户名的成员。 +type Account struct { + Member member.Member + Username string + Email string + PasswordHash string +} + +// 游客的两个字段都为空。 +type AccountProfile struct { + Username string + Email 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) + // name、avatarHash 或 email 为 nil 时保持原值,email 为空字符串表示清除。游客的 email 不保存。 + UpdateMember(ctx context.Context, id string, name, avatarHash, email *string) (member.Member, error) + AccountProfile(ctx context.Context, memberID string) (AccountProfile, error) + // 用户名已属于其他成员时返回 ErrUsernameTaken,成员已经注册过时返回 ErrAlreadyRegistered。 + Register(ctx context.Context, memberID, username, passwordHash string) error + AccountByUsername(ctx context.Context, username string) (Account, error) + // 在同一个事务中删除 oldTokenHash 的会话,给 memberID 创建 newTokenHash 的会话。oldTokenHash 的会话已经不存在时返回 + // ErrSessionNotFound;memberID 的密码哈希已经不是 passwordHash 时返回 ErrAccountPasswordChanged:密码在校验之后被修改过, + // 修改时对会话的删除已经执行,此时创建的会话不会再被删除。两种情况都不做任何修改。 + ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error + DeleteSession(ctx context.Context, tokenHash []byte) error + // 在同一个事务中修改已注册成员的密码哈希,并删除该成员除 keepTokenHash 以外的会话,keepTokenHash 为 nil 时全部删除。 + // 成员是游客时返回 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 7ab212e..4768f10 100644 --- a/internal/app/port/porttest/store.go +++ b/internal/app/port/porttest/store.go @@ -23,9 +23,11 @@ type Store struct { mu sync.Mutex ids int members []storedMember - rooms []port.RoomRecord - states map[string]*roomState - tickets map[string]storedTicket + // 会话的 token 哈希到成员 ID。 + sessions map[string]string + rooms []port.RoomRecord + states map[string]*roomState + tickets map[string]storedTicket // 房间 ID 到 bot 成员 ID。 bots map[string]string cookies map[string]string @@ -33,8 +35,10 @@ type Store struct { } type storedMember struct { - tokenHash string - member member.Member + member member.Member + username string + email string + passwordHash string } type storedTicket struct { @@ -54,10 +58,11 @@ var ( func New() *Store { return &Store{ - states: map[string]*roomState{}, - tickets: map[string]storedTicket{}, - bots: map[string]string{}, - cookies: map[string]string{}, + states: map[string]*roomState{}, + sessions: map[string]string{}, + tickets: map[string]storedTicket{}, + bots: map[string]string{}, + cookies: map[string]string{}, } } @@ -84,35 +89,48 @@ func (s *Store) CreateMember(_ context.Context, tokenHash []byte, name string) ( } s.mu.Lock() defer s.mu.Unlock() - for _, m := range s.members { - if m.tokenHash == string(tokenHash) { - return member.Member{}, errors.New("token 哈希重复") - } + if _, ok := s.sessions[string(tokenHash)]; ok { + return member.Member{}, errors.New("token 哈希重复") } m := member.Member{ID: s.newID(), Name: name} - s.members = append(s.members, storedMember{tokenHash: string(tokenHash), member: m}) + s.members = append(s.members, storedMember{member: m}) + s.sessions[string(tokenHash)] = m.ID return m, nil } func (s *Store) MemberByToken(_ context.Context, tokenHash []byte) (member.Member, error) { s.mu.Lock() defer s.mu.Unlock() - for _, m := range s.members { - if m.tokenHash == string(tokenHash) { - return m.member, nil - } + if m := s.member(s.sessions[string(tokenHash)]); m != nil { + return m.member, nil } return member.Member{}, port.ErrMemberNotFound } -func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash *string) (member.Member, error) { +// 调用方持有 s.mu。 +func (s *Store) member(id string) *storedMember { + for i := range s.members { + if s.members[i].member.ID == id { + return &s.members[i] + } + } + return nil +} + +func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash, email *string) (member.Member, error) { s.mu.Lock() defer s.mu.Unlock() for i := range s.members { - m := &s.members[i].member + stored := &s.members[i] + m := &stored.member if m.ID != id { continue } + if email != nil { + if err := checkText(*email); err != nil { + return member.Member{}, err + } + } if name != nil { if err := checkText(*name); err != nil { return member.Member{}, err @@ -122,11 +140,99 @@ func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash *str if avatarHash != nil { m.AvatarHash = *avatarHash } + if email != nil && stored.username != "" { + stored.email = *email + } return *m, nil } return member.Member{}, port.ErrMemberNotFound } +func (s *Store) AccountProfile(_ context.Context, memberID string) (port.AccountProfile, error) { + s.mu.Lock() + defer s.mu.Unlock() + m := s.member(memberID) + if m == nil { + return port.AccountProfile{}, port.ErrMemberNotFound + } + return port.AccountProfile{Username: m.username, Email: m.email}, 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, Email: m.email, PasswordHash: m.passwordHash}, nil + } + } + return port.Account{}, port.ErrMemberNotFound +} + +func (s *Store) ReplaceSession(_ context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error { + s.mu.Lock() + defer s.mu.Unlock() + m := s.member(memberID) + if m == nil { + return fmt.Errorf("成员 %s 不存在", memberID) + } + if m.passwordHash != passwordHash { + return port.ErrAccountPasswordChanged + } + if _, ok := s.sessions[string(oldTokenHash)]; !ok { + return port.ErrSessionNotFound + } + 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) 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() @@ -194,7 +300,7 @@ func (s *Store) ResetRoomAPIKey(_ context.Context, roomID string, k port.NewAPIK maps.DeleteFunc(s.tickets, func(_ string, t storedTicket) bool { return t.roomID == r.Info.ID && t.memberID == bot }) } else { bot = s.newID() - s.members = append(s.members, storedMember{tokenHash: string(k.BotTokenHash), member: member.Member{ID: bot, Name: "bot"}}) + s.members = append(s.members, storedMember{member: member.Member{ID: bot, Name: "bot"}}) s.bots[r.Info.ID] = bot } s.tickets[string(k.TokenHash)] = storedTicket{roomID: r.Info.ID, memberID: bot, createdAt: time.Now()} diff --git a/internal/app/port/porttest/store_test.go b/internal/app/port/porttest/store_test.go new file mode 100644 index 0000000..26f08ee --- /dev/null +++ b/internal/app/port/porttest/store_test.go @@ -0,0 +1,15 @@ +package porttest + +import "testing" + +func TestUpdateMemberRejectsTextPostgreSQLCannotStore(t *testing.T) { + store := New() + m, err := store.CreateMember(t.Context(), []byte("token"), "甲") + if err != nil { + t.Fatal(err) + } + bad := "a\x00@example.com" + if _, err := store.UpdateMember(t.Context(), m.ID, nil, nil, &bad); err == nil { + t.Error("含 NUL 的邮箱应报错") + } +}