// Package models 定義 Nestly 的資料結構與資料存取邏輯。 package models import ( "context" "errors" "regexp" "strings" "time" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) // AccountRole 使用者在系統中的角色。 type AccountRole string const ( // RoleMember 一般會員(買方/租客)。 RoleMember AccountRole = "member" // RoleOwner 屋主,可刊登自有物件。 RoleOwner AccountRole = "owner" // RoleAgent 房仲經紀人,可代管多筆物件。 RoleAgent AccountRole = "agent" // RoleAdmin 管理員,擁有系統全部權限。 RoleAdmin AccountRole = "admin" ) // Valid 回傳角色是否為系統定義的合法值。 func (r AccountRole) Valid() bool { switch r { case RoleMember, RoleOwner, RoleAgent, RoleAdmin: return true } return false } // String 實作 fmt.Stringer。 func (r AccountRole) String() string { return string(r) } const ( // MinPasswordLength 密碼最小長度。 MinPasswordLength = 8 // MaxPasswordLength 密碼最大長度,bcrypt 僅使用前 72 個位元組。 MaxPasswordLength = 72 // maxEmailLength 為 RFC 5321 允許的 email 最大長度。 maxEmailLength = 254 maxNameLength = 100 maxPhoneLength = 30 maxAvatarURLLength = 512 // DefaultPageSize 與 MaxPageSize 限制 List 的分頁大小。 DefaultPageSize = 20 MaxPageSize = 100 ) // 帳號相關的 sentinel errors,handler 可用 errors.Is 判斷後轉為對應的回應。 var ( ErrNotFound = errors.New("帳號不存在") ErrEmailExists = errors.New("email 已被註冊") ErrEmailRequired = errors.New("email 為必填") ErrEmailInvalid = errors.New("email 格式不正確") ErrNameRequired = errors.New("name 為必填") ErrNameTooLong = errors.New("name 長度過長") ErrRoleInvalid = errors.New("role 不合法") ErrPhoneTooLong = errors.New("phone 長度過長") ErrAvatarURLTooLong = errors.New("avatar_url 長度過長") ErrPasswordRequired = errors.New("尚未設定密碼") ErrPasswordTooShort = errors.New("密碼長度至少需 8 個字元") ErrPasswordTooLong = errors.New("密碼長度不可超過 72 個字元") ) var emailRegex = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) // Account 使用者帳號,對應資料庫中的 accounts 資料表。 type Account struct { ID uint `gorm:"primaryKey" json:"id"` Email string `gorm:"uniqueIndex;size:254;not null" json:"email"` PasswordHash string `gorm:"size:255;not null" json:"-"` Name string `gorm:"size:100;not null" json:"name"` Phone string `gorm:"size:30" json:"phone"` Role AccountRole `gorm:"size:20;not null;default:member" json:"role"` AvatarURL string `gorm:"size:512" json:"avatar_url"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } // Normalize 去除欄位多餘空白、將 email 統一為小寫,並補上預設角色。 func (a *Account) Normalize() { a.Email = strings.ToLower(strings.TrimSpace(a.Email)) a.Name = strings.TrimSpace(a.Name) a.Phone = strings.TrimSpace(a.Phone) a.AvatarURL = strings.TrimSpace(a.AvatarURL) if a.Role == "" { a.Role = RoleMember } } // SetPassword 驗證明文密碼長度後以 bcrypt 產生雜湊存入 PasswordHash。 func (a *Account) SetPassword(plain string) error { n := len(plain) if n < MinPasswordLength { return ErrPasswordTooShort } if n > MaxPasswordLength { return ErrPasswordTooLong } hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) if err != nil { return err } a.PasswordHash = string(hash) return nil } // VerifyPassword 比對明文密碼與儲存的雜湊是否相符。 func (a *Account) VerifyPassword(plain string) bool { return bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(plain)) == nil } // IsAdmin 回傳帳號是否為管理員。 func (a *Account) IsAdmin() bool { return a.Role == RoleAdmin } // Validate 檢查欄位是否合法(會先呼叫 Normalize),不可通過時回傳對應的 sentinel error。 // 寫入資料庫前帳號必須已透過 SetPassword 設定密碼。 func (a *Account) Validate() error { a.Normalize() switch { case a.Email == "": return ErrEmailRequired case !emailRegex.MatchString(a.Email) || len(a.Email) > maxEmailLength: return ErrEmailInvalid } switch { case a.Name == "": return ErrNameRequired case len(a.Name) > maxNameLength: return ErrNameTooLong } if !a.Role.Valid() { return ErrRoleInvalid } if a.Phone != "" && len(a.Phone) > maxPhoneLength { return ErrPhoneTooLong } if a.AvatarURL != "" && len(a.AvatarURL) > maxAvatarURLLength { return ErrAvatarURLTooLong } if a.PasswordHash == "" { return ErrPasswordRequired } return nil } // AccountStore 封裝 Account 的資料庫存取,所有方法皆帶 context。 type AccountStore struct { db *gorm.DB } // NewAccountStore 建立以 db 為後端的 AccountStore。 func NewAccountStore(db *gorm.DB) *AccountStore { return &AccountStore{db: db} } // AutoMigrate 建立或更新 accounts 資料表。 func (s *AccountStore) AutoMigrate(ctx context.Context) error { return s.db.WithContext(ctx).AutoMigrate(&Account{}) } // Create 驗證並新增帳號;email 已被註冊時回傳 ErrEmailExists。 func (s *AccountStore) Create(ctx context.Context, acct *Account) error { if err := acct.Validate(); err != nil { return err } err := s.db.WithContext(ctx). Where("email = ?", acct.Email). First(&Account{}).Error if err == nil { return ErrEmailExists } if !errors.Is(err, gorm.ErrRecordNotFound) { return err } return s.db.WithContext(ctx).Create(acct).Error } // FindByID 依主鍵查詢帳號,查無資料時回傳 ErrNotFound。 func (s *AccountStore) FindByID(ctx context.Context, id uint) (*Account, error) { var acct Account err := s.db.WithContext(ctx).First(&acct, id).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } if err != nil { return nil, err } return &acct, nil } // FindByEmail 依 email 查詢帳號(不分大小寫),查無資料時回傳 ErrNotFound。 func (s *AccountStore) FindByEmail(ctx context.Context, email string) (*Account, error) { var acct Account err := s.db.WithContext(ctx). Where("email = ?", strings.ToLower(strings.TrimSpace(email))). First(&acct).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } if err != nil { return nil, err } return &acct, nil } // List 分頁列出帳號(新註冊在前),回傳帳號清單與符合條件的總數;page 從 1 開始。 func (s *AccountStore) List(ctx context.Context, page, pageSize int) ([]Account, int64, error) { if page < 1 { page = 1 } if pageSize < 1 { pageSize = DefaultPageSize } if pageSize > MaxPageSize { pageSize = MaxPageSize } var total int64 if err := s.db.WithContext(ctx).Model(&Account{}).Count(&total).Error; err != nil { return nil, 0, err } var accounts []Account err := s.db.WithContext(ctx). Order("id DESC"). Limit(pageSize). Offset((page - 1) * pageSize). Find(&accounts).Error if err != nil { return nil, 0, err } return accounts, total, nil } // Update 驗證並儲存整個帳號;目標不存在時回傳 ErrNotFound, // email 改成其他帳號已使用的值時回傳 ErrEmailExists。 func (s *AccountStore) Update(ctx context.Context, acct *Account) error { if err := acct.Validate(); err != nil { return err } err := s.db.WithContext(ctx). Where("email = ? AND id <> ?", acct.Email, acct.ID). First(&Account{}).Error if err == nil { return ErrEmailExists } if !errors.Is(err, gorm.ErrRecordNotFound) { return err } result := s.db.WithContext(ctx).Save(acct) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return ErrNotFound } return nil } // Delete 軟刪除帳號,目標不存在時回傳 ErrNotFound。 func (s *AccountStore) Delete(ctx context.Context, id uint) error { result := s.db.WithContext(ctx).Delete(&Account{}, id) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return ErrNotFound } return nil }