feat: 定义用户模块 User 模型与 Repository 层 #94

Merged
huanghaosheng merged 5 commits from feature/user-mode-phase2 into develop 2026-06-14 17:03:47 +08:00
5 changed files with 461 additions and 0 deletions

View File

@@ -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"

View 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
}

View 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 &copy, 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 &copy, 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
}

View 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
}

View 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)
}
}