This commit is contained in:
2026-10-05 07:40:34 +08:00
parent 5a7ac2e5d9
commit d13088bfd5
22 changed files with 1369 additions and 0 deletions
+281
View File
@@ -0,0 +1,281 @@
// 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
}
+218
View File
@@ -0,0 +1,218 @@
package models
import (
"errors"
"strings"
"testing"
)
func TestAccountRole_Valid(t *testing.T) {
valid := []AccountRole{RoleMember, RoleOwner, RoleAgent, RoleAdmin, ""}
invalid := []AccountRole{"superuser", "MEMBER", "member ", "0"}
for _, role := range valid {
if got := role.Valid(); role != "" && !got {
t.Errorf("AccountRole(%q).Valid() = false, want true", role)
}
}
for _, role := range invalid {
if role.Valid() {
t.Errorf("AccountRole(%q).Valid() = true, want false", role)
}
}
if !RoleMember.Valid() {
t.Error("RoleMember.Valid() = false, want true")
}
}
func TestAccount_SetPassword(t *testing.T) {
tests := []struct {
name string
password string
wantErr error
}{
{name: "合法密碼", password: "s3cret!pass"},
{name: "剛好 8 字元", password: "12345678"},
{name: "太短", password: "1234567", wantErr: ErrPasswordTooShort},
{name: "空白", password: "", wantErr: ErrPasswordTooShort},
{name: "太長", password: strings.Repeat("a", MaxPasswordLength+1), wantErr: ErrPasswordTooLong},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
acct := &Account{}
err := acct.SetPassword(tt.password)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("SetPassword() error = %v, want %v", err, tt.wantErr)
}
if tt.wantErr == nil {
if acct.PasswordHash == "" {
t.Fatal("SetPassword() 後 PasswordHash 為空")
}
if acct.PasswordHash == tt.password {
t.Fatal("PasswordHash 不應儲存明文密碼")
}
}
})
}
}
func TestAccount_VerifyPassword(t *testing.T) {
acct := &Account{}
if err := acct.SetPassword("s3cret!pass"); err != nil {
t.Fatalf("SetPassword() error = %v", err)
}
if !acct.VerifyPassword("s3cret!pass") {
t.Error("VerifyPassword(正確密碼) = false, want true")
}
if acct.VerifyPassword("wrong-pass") {
t.Error("VerifyPassword(錯誤密碼) = true, want false")
}
if acct.VerifyPassword("") {
t.Error("VerifyPassword(空字串) = true, want false")
}
}
func TestAccount_VerifyPassword_每次雜湊不同(t *testing.T) {
a1, a2 := &Account{}, &Account{}
if err := a1.SetPassword("same-password"); err != nil {
t.Fatalf("a1.SetPassword() error = %v", err)
}
if err := a2.SetPassword("same-password"); err != nil {
t.Fatalf("a2.SetPassword() error = %v", err)
}
if a1.PasswordHash == a2.PasswordHash {
t.Error("相同密碼的兩次雜湊應不同(bcrypt 應加鹽)")
}
if !a1.VerifyPassword("same-password") || !a2.VerifyPassword("same-password") {
t.Error("兩個帳號都應能以原始密碼通過驗證")
}
}
func TestAccount_Validate(t *testing.T) {
valid := func() *Account {
acct := &Account{Email: "dan@example.com", Name: "Dan"}
if err := acct.SetPassword("s3cret!pass"); err != nil {
t.Fatalf("SetPassword() error = %v", err)
}
return acct
}
tests := []struct {
name string
mutate func(*Account)
wantErr error
}{
{name: "合法帳號", mutate: func(*Account) {}},
{
name: "缺 email",
mutate: func(a *Account) { a.Email = "" },
wantErr: ErrEmailRequired,
},
{
name: "email 格式錯誤",
mutate: func(a *Account) { a.Email = "not-an-email" },
wantErr: ErrEmailInvalid,
},
{
name: "email 過長",
mutate: func(a *Account) { a.Email = strings.Repeat("a", 250) + "@example.com" },
wantErr: ErrEmailInvalid,
},
{
name: "缺 name",
mutate: func(a *Account) { a.Name = "" },
wantErr: ErrNameRequired,
},
{
name: "name 過長",
mutate: func(a *Account) { a.Name = strings.Repeat("名", 101) },
wantErr: ErrNameTooLong,
},
{
name: "role 不合法",
mutate: func(a *Account) { a.Role = "hacker" },
wantErr: ErrRoleInvalid,
},
{
name: "phone 過長",
mutate: func(a *Account) { a.Phone = strings.Repeat("0", 31) },
wantErr: ErrPhoneTooLong,
},
{
name: "avatar_url 過長",
mutate: func(a *Account) { a.AvatarURL = strings.Repeat("x", 513) },
wantErr: ErrAvatarURLTooLong,
},
{
name: "未設定密碼",
mutate: func(a *Account) { a.PasswordHash = "" },
wantErr: ErrPasswordRequired,
},
{
name: "所有合法角色可通過",
mutate: func(a *Account) {
a.Role = RoleAdmin
a.Phone = "0912345678"
a.AvatarURL = "https://example.com/a.png"
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
acct := valid()
tt.mutate(acct)
err := acct.Validate()
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
}
})
}
}
func TestAccount_Normalize(t *testing.T) {
acct := &Account{
Email: " Dan@Example.COM ",
Name: " 陳大同 ",
Phone: " 0912345678 ",
AvatarURL: " https://example.com/a.png ",
}
acct.Normalize()
if acct.Email != "dan@example.com" {
t.Errorf("Email = %q, want %q", acct.Email, "dan@example.com")
}
if acct.Name != "陳大同" {
t.Errorf("Name = %q, want %q", acct.Name, "陳大同")
}
if acct.Phone != "0912345678" {
t.Errorf("Phone = %q, want %q", acct.Phone, "0912345678")
}
if acct.AvatarURL != "https://example.com/a.png" {
t.Errorf("AvatarURL = %q, want %q", acct.AvatarURL, "https://example.com/a.png")
}
if acct.Role != RoleMember {
t.Errorf("空角色應預設為 RoleMember, got %q", acct.Role)
}
}
func TestAccount_Normalize_不覆寫已設角色(t *testing.T) {
acct := &Account{Role: RoleAgent}
acct.Normalize()
if acct.Role != RoleAgent {
t.Errorf("Role = %q, want %q", acct.Role, RoleAgent)
}
}
func TestAccount_IsAdmin(t *testing.T) {
admin := &Account{Role: RoleAdmin}
member := &Account{Role: RoleMember}
if !admin.IsAdmin() {
t.Error("admin.IsAdmin() = false, want true")
}
if member.IsAdmin() {
t.Error("member.IsAdmin() = true, want false")
}
}