282 lines
8.1 KiB
Go
282 lines
8.1 KiB
Go
// 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
|
||
}
|