Merge pull request 'feat: 定义用户模块 User 模型与 Repository 层' (#94) from feature/user-mode-phase2 into develop
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/94
This commit was merged in pull request #94.
This commit is contained in:
@@ -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"
|
||||
|
||||
46
backend/internal/store/user.go
Normal file
46
backend/internal/store/user.go
Normal file
@@ -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
|
||||
}
|
||||
120
backend/internal/store/user_mem.go
Normal file
120
backend/internal/store/user_mem.go
Normal file
@@ -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
|
||||
}
|
||||
101
backend/internal/store/user_pg.go
Normal file
101
backend/internal/store/user_pg.go
Normal file
@@ -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
|
||||
}
|
||||
185
backend/internal/store/user_test.go
Normal file
185
backend/internal/store/user_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user