Files
nestly/internal/models/account.go
T
2026-10-05 07:40:34 +08:00

282 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}