From 9f7dbbd470b0111f0212a64799dd945beaa6394b Mon Sep 17 00:00:00 2001 From: huang <1724659546@qq.com> Date: Thu, 11 Sep 2025 23:00:48 +0800 Subject: [PATCH] =?UTF-8?q?1.=20=E9=87=8D=E5=86=99user=20model=E5=92=8Cuse?= =?UTF-8?q?r=5Frepository=202.=20=E5=A2=9E=E5=8A=A0=E5=AF=B9=E5=BA=94?= =?UTF-8?q?=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/models/user.go | 55 +++++++++++ internal/models/user_test.go | 44 +++++++++ .../storage/repository/user_repository.go | 49 ++++++++++ .../repository/user_repository_test.go | 93 +++++++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 internal/models/user.go create mode 100644 internal/models/user_test.go create mode 100644 internal/storage/repository/user_repository.go create mode 100644 internal/storage/repository/user_repository_test.go diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..6b42d65 --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,55 @@ +// Package models 定义了应用的数据模型,例如用户、产品等。 +package models + +import ( + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// User 代表系统中的用户模型 +type User struct { + // gorm.Model 内嵌了 ID, CreatedAt, UpdatedAt, 和 DeletedAt + // DeletedAt 字段的存在自动为 GORM 开启了软删除模式 + gorm.Model + + // Username 是用户的登录名,应该是唯一的 + // 修正了 gorm 标签的拼写错误 (移除了 gorm 后面的冒号) + Username string `gorm:"unique;not null" json:"username"` + + // Password 存储的是加密后的密码哈希,而不是明文 + // json:"-" 标签确保此字段在序列化为 JSON 时被忽略,防止密码泄露 + Password string `gorm:"not null" json:"-"` +} + +// TableName 自定义 User 模型对应的数据库表名 +// GORM 默认会使用复数形式 "users",但显式定义是一种好习惯 +func (User) TableName() string { + return "users" +} + +// --- GORM Hooks --- + +// BeforeCreate 是一个 GORM 钩子,在创建用户记录前自动调用。 +// 这是哈希初始密码最可靠的地方。 +func (u *User) BeforeCreate(tx *gorm.DB) (err error) { + // 如果密码不为空,则执行哈希 + if u.Password != "" { + // 使用 bcrypt 对密码进行哈希 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) + if err != nil { + return err + } + // 将明文密码替换为哈希值 + u.Password = string(hashedPassword) + } + return nil +} + +// --- Helper Methods --- + +// CheckPassword 用于验证输入的明文密码是否与数据库中存储的哈希匹配 +func (u *User) CheckPassword(plainPassword string) bool { + // bcrypt.CompareHashAndPassword 会安全地比较哈希和明文,能有效防止时序攻击 + err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(plainPassword)) + return err == nil +} diff --git a/internal/models/user_test.go b/internal/models/user_test.go new file mode 100644 index 0000000..ff2bbf0 --- /dev/null +++ b/internal/models/user_test.go @@ -0,0 +1,44 @@ +// Package models_test 包含对 models 包的单元测试 +package models_test + +import ( + "testing" + + "git.huangwc.com/pig/pig-farm-controller/internal/models" + "github.com/stretchr/testify/assert" + "golang.org/x/crypto/bcrypt" +) + +func TestUser_CheckPassword(t *testing.T) { + plainPassword := "my-secret-password" + + // 1. 生成一个密码哈希用于测试 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost) + assert.NoError(t, err, "生成密码哈希不应出错") + + user := &models.User{ + Password: string(hashedPassword), + } + + t.Run("密码正确", func(t *testing.T) { + // 2. 使用正确的明文密码进行校验 + match := user.CheckPassword(plainPassword) + assert.True(t, match, "正确的密码应该校验通过") + }) + + t.Run("密码错误", func(t *testing.T) { + // 3. 使用错误的明文密码进行校验 + match := user.CheckPassword("wrong-password") + assert.False(t, match, "错误的密码应该校验失败") + }) + + t.Run("空密码", func(t *testing.T) { + // 4. 使用空字符串作为密码进行校验 + match := user.CheckPassword("") + assert.False(t, match, "空密码应该校验失败") + }) +} + +// 注意:BeforeSave 钩子是一个 GORM 框架的回调,它的正确性 +// 将在 repository 的集成测试中,通过实际创建一个用户来得到验证, +// 而不是在这里进行孤立的、脆弱的单元测试。 diff --git a/internal/storage/repository/user_repository.go b/internal/storage/repository/user_repository.go new file mode 100644 index 0000000..d1c2122 --- /dev/null +++ b/internal/storage/repository/user_repository.go @@ -0,0 +1,49 @@ +// Package repository 提供了数据访问的仓库实现 +package repository + +import ( + "git.huangwc.com/pig/pig-farm-controller/internal/models" + "gorm.io/gorm" +) + +// UserRepository 定义了与用户模型相关的数据库操作接口 +// 这是为了让业务逻辑层依赖于抽象,而不是具体的数据库实现 +type UserRepository interface { + Create(user *models.User) error + FindByUsername(username string) (*models.User, error) + FindByID(id uint) (*models.User, error) +} + +// gormUserRepository 是 UserRepository 的 GORM 实现 +type gormUserRepository struct { + db *gorm.DB +} + +// NewGormUserRepository 创建一个新的 UserRepository GORM 实现实例 +func NewGormUserRepository(db *gorm.DB) UserRepository { + return &gormUserRepository{db: db} +} + +// Create 创建一个新的用户记录 +func (r *gormUserRepository) Create(user *models.User) error { + // BeforeSave 钩子会在这里被自动触发 + return r.db.Create(user).Error +} + +// FindByUsername 根据用户名查找用户 +func (r *gormUserRepository) FindByUsername(username string) (*models.User, error) { + var user models.User + if err := r.db.Where("username = ?", username).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +// FindByID 根据 ID 查找用户 +func (r *gormUserRepository) FindByID(id uint) (*models.User, error) { + var user models.User + if err := r.db.First(&user, id).Error; err != nil { + return nil, err + } + return &user, nil +} diff --git a/internal/storage/repository/user_repository_test.go b/internal/storage/repository/user_repository_test.go new file mode 100644 index 0000000..965e679 --- /dev/null +++ b/internal/storage/repository/user_repository_test.go @@ -0,0 +1,93 @@ +// Package repository_test 包含对 repository 包的集成测试 +package repository_test + +import ( + "testing" + + "git.huangwc.com/pig/pig-farm-controller/internal/models" + "git.huangwc.com/pig/pig-farm-controller/internal/storage/repository" + "github.com/stretchr/testify/assert" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// setupTestDB 是一个辅助函数,用于为每个测试创建一个 +// 干净的、内存中的 SQLite 数据库实例。 +func setupTestDB(t *testing.T) *gorm.DB { + // "file::memory:?cache=shared" 是 GORM 连接内存 SQLite 的标准方式 + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + assert.NoError(t, err, "连接内存数据库不应出错") + + // 自动迁移 User 表结构 + err = db.AutoMigrate(&models.User{}) + assert.NoError(t, err, "数据库迁移不应出错") + + return db +} + +func TestGormUserRepository(t *testing.T) { + db := setupTestDB(t) + repo := repository.NewGormUserRepository(db) + + plainPassword := "my-secret-password" + userToCreate := &models.User{ + Username: "testuser", + Password: plainPassword, // 我们提供的是明文密码 + } + + t.Run("Create - 成功创建并验证密码哈希", func(t *testing.T) { + err := repo.Create(userToCreate) + assert.NoError(t, err) + + // 验证用户已被创建 + assert.NotZero(t, userToCreate.ID) + + // 从数据库中直接取回记录,以验证 BeforeSave 钩子是否生效 + var savedUser models.User + db.First(&savedUser, userToCreate.ID) + + // 验证密码字段存储的不是明文 + assert.NotEqual(t, plainPassword, savedUser.Password, "数据库中存储的密码不应是明文") + + // 验证存储的哈希是正确的 + assert.True(t, savedUser.CheckPassword(plainPassword), "存储的密码哈希应该能与原明文匹配") + }) + + t.Run("Create - 用户名冲突", func(t *testing.T) { + // 尝试创建一个同名用户 + duplicateUser := &models.User{Username: "testuser", Password: "anypassword"} + err := repo.Create(duplicateUser) + + // 我们期望一个错误,因为用户名是唯一的 + assert.Error(t, err, "创建同名用户应该返回错误") + // 更精确地,可以检查是否是唯一键冲突错误 + assert.Contains(t, err.Error(), "UNIQUE constraint failed: users.username", "错误信息应包含唯一键冲突") + }) + + t.Run("FindByUsername - 找到用户", func(t *testing.T) { + foundUser, err := repo.FindByUsername("testuser") + assert.NoError(t, err) + assert.NotNil(t, foundUser) + assert.Equal(t, userToCreate.ID, foundUser.ID) + assert.Equal(t, "testuser", foundUser.Username) + }) + + t.Run("FindByUsername - 未找到用户", func(t *testing.T) { + _, err := repo.FindByUsername("nonexistent") + assert.Error(t, err, "查找不存在的用户应该返回错误") + assert.ErrorIs(t, err, gorm.ErrRecordNotFound, "错误类型应为 gorm.ErrRecordNotFound") + }) + + t.Run("FindByID - 找到用户", func(t *testing.T) { + foundUser, err := repo.FindByID(userToCreate.ID) + assert.NoError(t, err) + assert.NotNil(t, foundUser) + assert.Equal(t, userToCreate.ID, foundUser.ID) + }) + + t.Run("FindByID - 未找到用户", func(t *testing.T) { + _, err := repo.FindByID(99999) + assert.Error(t, err, "查找不存在的ID应该返回错误") + assert.ErrorIs(t, err, gorm.ErrRecordNotFound, "错误类型应为 gorm.ErrRecordNotFound") + }) +}