From c9c174f400d85a4862dc630d2bbe85e4993e2128 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:53:54 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=20models=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20User=20=E7=BB=93=E6=9E=84=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/models/models.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index b07924c..e83680b 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -41,6 +41,15 @@ func (p SessionConfigPatch) Apply(cfg *SessionConfig) { } } +// User 用户。 +type User struct { + ID string `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // Message 对话消息。 type Message struct { Role string `json:"role"` // "user" | "assistant" From af73e78aa3cd2ac68f5b87b8cb9f7e9fc3b350ef Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:54:21 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E5=AE=9A=E4=B9=89=20UserRepository?= =?UTF-8?q?=20=E6=8E=A5=E5=8F=A3=E5=8F=8A=20User=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/store/user.go | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 backend/internal/store/user.go diff --git a/backend/internal/store/user.go b/backend/internal/store/user.go new file mode 100644 index 0000000..40dcb78 --- /dev/null +++ b/backend/internal/store/user.go @@ -0,0 +1,46 @@ +package store + +import ( + "context" + "errors" + "time" +) + +var ( + ErrUserNotFound = errors.New("user not found") + ErrUsernameTaken = errors.New("username already taken") + ErrRefreshTokenNotFound = errors.New("refresh token not found") +) + +// UserRepository 用户持久化接口。 +type UserRepository interface { + // Create 创建用户,返回生成的 ID。 + Create(ctx context.Context, username, passwordHash string) (string, error) + + // FindByUsername 按用户名查找,不存在返回 ErrUserNotFound。 + FindByUsername(ctx context.Context, username string) (*User, error) + + // FindByID 按 ID 查找,不存在返回 ErrUserNotFound。 + FindByID(ctx context.Context, id string) (*User, error) + + // SaveRefreshToken 保存 refresh token hash。 + SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error + + // FindRefreshToken 按 token hash 查找,返回 user_id。不存在返回 ErrRefreshTokenNotFound。 + FindRefreshToken(ctx context.Context, tokenHash string) (string, error) + + // DeleteRefreshToken 按 token hash 删除。 + DeleteRefreshToken(ctx context.Context, tokenHash string) error + + // DeleteUserRefreshTokens 删除用户的所有 refresh token(登出所有设备)。 + DeleteUserRefreshTokens(ctx context.Context, userID string) error +} + +// User 用户数据模型(store 层)。 +type User struct { + ID string + Username string + PasswordHash string + CreatedAt time.Time + UpdatedAt time.Time +} From 36827edfeac4d0924d6037b18f47672f30bf0ac9 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:55:12 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20PostgreSQL=20U?= =?UTF-8?q?serRepository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/store/user_pg.go | 101 ++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 backend/internal/store/user_pg.go diff --git a/backend/internal/store/user_pg.go b/backend/internal/store/user_pg.go new file mode 100644 index 0000000..fec0631 --- /dev/null +++ b/backend/internal/store/user_pg.go @@ -0,0 +1,101 @@ +package store + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PgUserRepository 基于 PostgreSQL 的 UserRepository 实现。 +type PgUserRepository struct { + pool *pgxpool.Pool +} + +// NewPgUserRepository 创建 PgUserRepository。 +func NewPgUserRepository(pool *pgxpool.Pool) *PgUserRepository { + return &PgUserRepository{pool: pool} +} + +func (r *PgUserRepository) Create(ctx context.Context, username, passwordHash string) (string, error) { + var id string + err := r.pool.QueryRow(ctx, + `INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id`, + username, passwordHash, + ).Scan(&id) + if err != nil { + return "", err + } + return id, nil +} + +func (r *PgUserRepository) FindByUsername(ctx context.Context, username string) (*User, error) { + var u User + err := r.pool.QueryRow(ctx, + `SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = $1`, + username, + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, err + } + return &u, nil +} + +func (r *PgUserRepository) FindByID(ctx context.Context, id string) (*User, error) { + var u User + err := r.pool.QueryRow(ctx, + `SELECT id, username, password_hash, created_at, updated_at FROM users WHERE id = $1`, + id, + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, err + } + return &u, nil +} + +func (r *PgUserRepository) SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { + _, err := r.pool.Exec(ctx, + `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, + userID, tokenHash, expiresAt, + ) + return err +} + +func (r *PgUserRepository) FindRefreshToken(ctx context.Context, tokenHash string) (string, error) { + var userID string + err := r.pool.QueryRow(ctx, + `SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW()`, + tokenHash, + ).Scan(&userID) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrRefreshTokenNotFound + } + if err != nil { + return "", err + } + return userID, nil +} + +func (r *PgUserRepository) DeleteRefreshToken(ctx context.Context, tokenHash string) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM refresh_tokens WHERE token_hash = $1`, + tokenHash, + ) + return err +} + +func (r *PgUserRepository) DeleteUserRefreshTokens(ctx context.Context, userID string) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM refresh_tokens WHERE user_id = $1`, + userID, + ) + return err +} From ce13e5a0481cee5486bb57ed0283bbabb2b8d6b2 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:55:44 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=86=85?= =?UTF-8?q?=E5=AD=98=20UserRepository=EF=BC=88=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/store/user_mem.go | 120 +++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 backend/internal/store/user_mem.go diff --git a/backend/internal/store/user_mem.go b/backend/internal/store/user_mem.go new file mode 100644 index 0000000..73a1be2 --- /dev/null +++ b/backend/internal/store/user_mem.go @@ -0,0 +1,120 @@ +package store + +import ( + "context" + "sync" + "time" + + "github.com/google/uuid" +) + +// MemUserRepository 基于内存的 UserRepository 实现(测试用)。 +type MemUserRepository struct { + mu sync.RWMutex + users map[string]*User // id -> user + byUsername map[string]string // username -> id + refreshTokens map[string]string // tokenHash -> userID + tokenExpiry map[string]time.Time // tokenHash -> expiresAt +} + +// NewMemUserRepository 创建 MemUserRepository。 +func NewMemUserRepository() *MemUserRepository { + return &MemUserRepository{ + users: make(map[string]*User), + byUsername: make(map[string]string), + refreshTokens: make(map[string]string), + tokenExpiry: make(map[string]time.Time), + } +} + +func (r *MemUserRepository) Create(_ context.Context, username, passwordHash string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.byUsername[username]; exists { + return "", ErrUsernameTaken + } + + id := uuid.New().String() + now := time.Now() + user := &User{ + ID: id, + Username: username, + PasswordHash: passwordHash, + CreatedAt: now, + UpdatedAt: now, + } + r.users[id] = user + r.byUsername[username] = id + return id, nil +} + +func (r *MemUserRepository) FindByUsername(_ context.Context, username string) (*User, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + id, ok := r.byUsername[username] + if !ok { + return nil, ErrUserNotFound + } + u := r.users[id] + copy := *u + return ©, nil +} + +func (r *MemUserRepository) FindByID(_ context.Context, id string) (*User, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + u, ok := r.users[id] + if !ok { + return nil, ErrUserNotFound + } + copy := *u + return ©, nil +} + +func (r *MemUserRepository) SaveRefreshToken(_ context.Context, userID, tokenHash string, expiresAt time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.refreshTokens[tokenHash] = userID + r.tokenExpiry[tokenHash] = expiresAt + return nil +} + +func (r *MemUserRepository) FindRefreshToken(_ context.Context, tokenHash string) (string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + userID, ok := r.refreshTokens[tokenHash] + if !ok { + return "", ErrRefreshTokenNotFound + } + if time.Now().After(r.tokenExpiry[tokenHash]) { + return "", ErrRefreshTokenNotFound + } + return userID, nil +} + +func (r *MemUserRepository) DeleteRefreshToken(_ context.Context, tokenHash string) error { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.refreshTokens, tokenHash) + delete(r.tokenExpiry, tokenHash) + return nil +} + +func (r *MemUserRepository) DeleteUserRefreshTokens(_ context.Context, userID string) error { + r.mu.Lock() + defer r.mu.Unlock() + + for hash, uid := range r.refreshTokens { + if uid == userID { + delete(r.refreshTokens, hash) + delete(r.tokenExpiry, hash) + } + } + return nil +} From 9842b4a45723872c45c231c859eada00588139f5 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:56:29 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=E7=BC=96=E5=86=99=20UserRepository?= =?UTF-8?q?=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/store/user_test.go | 185 ++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 backend/internal/store/user_test.go diff --git a/backend/internal/store/user_test.go b/backend/internal/store/user_test.go new file mode 100644 index 0000000..d335933 --- /dev/null +++ b/backend/internal/store/user_test.go @@ -0,0 +1,185 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" +) + +// newUserRepo 返回一个可测试的 UserRepository 实现。 +// 如需测试 Pg 实现,可在此替换为连接真实 DB 的版本。 +func newUserRepo() UserRepository { + return NewMemUserRepository() +} + +func TestUserRepository_Create(t *testing.T) { + repo := newUserRepo() + ctx := context.Background() + + id, err := repo.Create(ctx, "alice", "hash123") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + if id == "" { + t.Fatal("expected non-empty ID") + } + + // 重复用户名应返回 ErrUsernameTaken + _, err = repo.Create(ctx, "alice", "hash456") + if !errors.Is(err, ErrUsernameTaken) { + t.Fatalf("expected ErrUsernameTaken, got %v", err) + } +} + +func TestUserRepository_FindByUsername(t *testing.T) { + repo := newUserRepo() + ctx := context.Background() + + _, err := repo.Create(ctx, "bob", "hash_bob") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + user, err := repo.FindByUsername(ctx, "bob") + if err != nil { + t.Fatalf("FindByUsername failed: %v", err) + } + if user.Username != "bob" { + t.Fatalf("expected username bob, got %s", user.Username) + } + if user.PasswordHash != "hash_bob" { + t.Fatalf("expected password hash hash_bob, got %s", user.PasswordHash) + } + + // 不存在的用户 + _, err = repo.FindByUsername(ctx, "nobody") + if !errors.Is(err, ErrUserNotFound) { + t.Fatalf("expected ErrUserNotFound, got %v", err) + } +} + +func TestUserRepository_FindByID(t *testing.T) { + repo := newUserRepo() + ctx := context.Background() + + id, err := repo.Create(ctx, "charlie", "hash_charlie") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + user, err := repo.FindByID(ctx, id) + if err != nil { + t.Fatalf("FindByID failed: %v", err) + } + if user.ID != id { + t.Fatalf("expected ID %s, got %s", id, user.ID) + } + if user.Username != "charlie" { + t.Fatalf("expected username charlie, got %s", user.Username) + } + + // 不存在的 ID + _, err = repo.FindByID(ctx, "nonexistent-uuid") + if !errors.Is(err, ErrUserNotFound) { + t.Fatalf("expected ErrUserNotFound, got %v", err) + } +} + +func TestUserRepository_RefreshToken(t *testing.T) { + repo := newUserRepo() + ctx := context.Background() + + userID, err := repo.Create(ctx, "dave", "hash_dave") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + tokenHash := "abc123hash" + expiresAt := time.Now().Add(7 * 24 * time.Hour) + + // 保存 token + if err := repo.SaveRefreshToken(ctx, userID, tokenHash, expiresAt); err != nil { + t.Fatalf("SaveRefreshToken failed: %v", err) + } + + // 查找 token + foundUserID, err := repo.FindRefreshToken(ctx, tokenHash) + if err != nil { + t.Fatalf("FindRefreshToken failed: %v", err) + } + if foundUserID != userID { + t.Fatalf("expected userID %s, got %s", userID, foundUserID) + } + + // 不存在的 token + _, err = repo.FindRefreshToken(ctx, "nonexistent") + if !errors.Is(err, ErrRefreshTokenNotFound) { + t.Fatalf("expected ErrRefreshTokenNotFound, got %v", err) + } + + // 删除 token + if err := repo.DeleteRefreshToken(ctx, tokenHash); err != nil { + t.Fatalf("DeleteRefreshToken failed: %v", err) + } + _, err = repo.FindRefreshToken(ctx, tokenHash) + if !errors.Is(err, ErrRefreshTokenNotFound) { + t.Fatalf("expected ErrRefreshTokenNotFound after delete, got %v", err) + } +} + +func TestUserRepository_DeleteUserRefreshTokens(t *testing.T) { + repo := newUserRepo() + ctx := context.Background() + + userID, err := repo.Create(ctx, "eve", "hash_eve") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + // 保存多个 token + for i := 0; i < 3; i++ { + tokenHash := "token_" + string(rune('a'+i)) + expiresAt := time.Now().Add(7 * 24 * time.Hour) + if err := repo.SaveRefreshToken(ctx, userID, tokenHash, expiresAt); err != nil { + t.Fatalf("SaveRefreshToken failed: %v", err) + } + } + + // 删除用户所有 token + if err := repo.DeleteUserRefreshTokens(ctx, userID); err != nil { + t.Fatalf("DeleteUserRefreshTokens failed: %v", err) + } + + // 验证全部删除 + for i := 0; i < 3; i++ { + tokenHash := "token_" + string(rune('a'+i)) + _, err := repo.FindRefreshToken(ctx, tokenHash) + if !errors.Is(err, ErrRefreshTokenNotFound) { + t.Fatalf("expected ErrRefreshTokenNotFound for token_%c, got %v", 'a'+i, err) + } + } +} + +func TestUserRepository_ExpiredRefreshToken(t *testing.T) { + repo := newUserRepo() + ctx := context.Background() + + userID, err := repo.Create(ctx, "frank", "hash_frank") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + tokenHash := "expired_token" + expiresAt := time.Now().Add(-1 * time.Hour) // 已过期 + + if err := repo.SaveRefreshToken(ctx, userID, tokenHash, expiresAt); err != nil { + t.Fatalf("SaveRefreshToken failed: %v", err) + } + + // 过期 token 应返回 ErrRefreshTokenNotFound + _, err = repo.FindRefreshToken(ctx, tokenHash) + if !errors.Is(err, ErrRefreshTokenNotFound) { + t.Fatalf("expected ErrRefreshTokenNotFound for expired token, got %v", err) + } +}