diff --git a/db/migrations/00003_member_sessions.sql b/db/migrations/00003_member_sessions.sql new file mode 100644 index 0000000..dc4bb68 --- /dev/null +++ b/db/migrations/00003_member_sessions.sql @@ -0,0 +1,16 @@ +-- +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; diff --git a/db/queries/members.sql b/db/queries/members.sql index 64afd3a..a6443f7 100644 --- a/db/queries/members.sql +++ b/db/queries/members.sql @@ -1,9 +1,12 @@ -- 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 -- 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。 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..5b89cc3 100644 --- a/internal/adapter/out/postgres/internal/query/members.sql.go +++ b/internal/adapter/out/postgres/internal/query/members.sql.go @@ -10,21 +10,15 @@ import ( ) const createMember = `-- name: CreateMember :one -INSERT INTO members (token_hash, name) VALUES ($1, $2) -RETURNING id, token_hash, name, avatar_hash, created_at +INSERT INTO members (name) VALUES ($1) +RETURNING id, name, avatar_hash, created_at ` -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) CreateMember(ctx context.Context, name string) (Member, error) { + row := q.db.QueryRow(ctx, createMember, name) var i Member err := row.Scan( &i.ID, - &i.TokenHash, &i.Name, &i.AvatarHash, &i.CreatedAt, @@ -32,8 +26,22 @@ func (q *Queries) CreateMember(ctx context.Context, arg CreateMemberParams) (Mem 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 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 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,7 +49,6 @@ 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, @@ -54,7 +61,7 @@ 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 +RETURNING id, name, avatar_hash, created_at ` type UpdateMemberParams struct { @@ -69,7 +76,6 @@ func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Mem var i Member err := row.Scan( &i.ID, - &i.TokenHash, &i.Name, &i.AvatarHash, &i.CreatedAt, diff --git a/internal/adapter/out/postgres/internal/query/models.go b/internal/adapter/out/postgres/internal/query/models.go index 2cb8ec7..6756aa0 100644 --- a/internal/adapter/out/postgres/internal/query/models.go +++ b/internal/adapter/out/postgres/internal/query/models.go @@ -10,7 +10,6 @@ import ( type Member struct { ID string - TokenHash []byte Name string AvatarHash *string CreatedAt time.Time diff --git a/internal/adapter/out/postgres/members.go b/internal/adapter/out/postgres/members.go index a90ce23..7319456 100644 --- a/internal/adapter/out/postgres/members.go +++ b/internal/adapter/out/postgres/members.go @@ -15,11 +15,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) { 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/app/identity/identity.go b/internal/app/identity/identity.go index a160861..f3068de 100644 --- a/internal/app/identity/identity.go +++ b/internal/app/identity/identity.go @@ -131,9 +131,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..00c3a9d 100644 --- a/internal/app/port/member.go +++ b/internal/app/port/member.go @@ -9,7 +9,7 @@ import ( var ErrMemberNotFound = errors.New("成员不存在") -// 成员 token 只以 SHA-256 哈希的形式出现在端口中。 +// 成员 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) diff --git a/internal/app/port/porttest/store.go b/internal/app/port/porttest/store.go index 7ab212e..1279249 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,7 @@ type Store struct { } type storedMember struct { - tokenHash string - member member.Member + member member.Member } type storedTicket struct { @@ -54,10 +55,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,27 +86,34 @@ 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 } +// 调用方持有 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 *string) (member.Member, error) { s.mu.Lock() defer s.mu.Unlock() @@ -194,7 +203,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()}