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 对话消息。
|
// Message 对话消息。
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Role string `json:"role"` // "user" | "assistant"
|
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