Files
mailgo/internal/store/user_store.go
T
dsh c881146a40 feat(web): 多语言界面(中/英/日)与用户语言偏好字段
- 新增 internal/i18n 包:以原始中文字符串为 key 的翻译目录(zh 恒等,
  en 兜底,ja 缺译回退 en),t/tf 模板函数与 Accept-Language 解析
  (按 q 值排序,兜底英语)
- User 模型新增 language 字段(auto|en|zh|ja,默认 auto),
  AutoMigrate 自动加列;UserStore 新增 UpdateLanguage
- 语言解析:登录用户按偏好(auto 时取浏览器 Accept-Language),
  登录/封禁页按浏览器语言;所有 c.HTML 渲染注入 Lang 模板数据
- 全部页面模板(用户页 + 管理后台 12 页)接入翻译:导航、文件夹、
  邮件列表/阅读/写信、设置、登录、封禁页、表单、表格、JS 确认框等;
  html lang 属性随语言切换;日期函数(shortDate/time12/time12m)
  与 folderLabel 改为语言感知(上午/下午 vs AM/PM vs 午前/午後)
- 设置页新增「界面语言」选择(自动/English/中文/日本語),
  /settings 表单支持单独提交语言偏好;管理后台用户表单可预设语言
- 封禁原因等入库中文文案在展示时经 t 翻译(未知 key 原样回退)
- 测试:i18n 单测(解析/回退/译文)、render 测试补 Lang 数据、
  时间格式化测试适配新签名;全量 go test 通过
2026-08-20 00:21:10 -04:00

240 lines
8.0 KiB
Go

package store
import (
"strings"
"mail_go/internal/db"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// UserStore defines the interface for user data operations.
type UserStore interface {
Create(user *db.User) error
GetByID(id uint) (*db.User, error)
GetByUsername(username string, domainID uint) (*db.User, error)
GetByEmail(email string) (*db.User, error)
Authenticate(email, password string) (*db.User, error)
// AuthenticateLogin 协议层登录(IMAP/SMTP/POP3):与 Authenticate 相同,
// 但支持裸用户名(如 "kevin"),自动解析到其唯一所属域名;多域名下
// 用户名存在歧义时要求完整邮箱。兼容手机/客户端只填用户名的配置。
AuthenticateLogin(login, password string) (*db.User, error)
// LoginExists 判断登录名(完整邮箱或裸用户名)是否对应系统中的用户,
// 供封禁逻辑区分“真实用户输错密码”(保留宽限)与“枚举型爆破”
// (跳过宽限,见 RecordAuthFailure 的 knownUser 参数)。
LoginExists(login string) bool
Update(user *db.User) error
Delete(id uint) error
List(domainID uint, page, size int) ([]db.User, int64, error)
ListAll(page, size int) ([]db.User, int64, error)
UpdateUsedBytes(id uint, delta int64) error
UpdatePassword(userID uint, hashedPassword string) error
// UpdateLanguage 更新用户界面语言偏好(auto | en | zh | ja)。
UpdateLanguage(userID uint, lang string) error
// TryReserveQuota 原子预扣 delta 字节:仅在不超过配额时生效并返回 true,
// 否则不做任何修改返回 false。防止并发提交绕过配额检查(TOCTOU)。
TryReserveQuota(userID uint, delta int64) (bool, error)
}
// userStoreGorm implements UserStore using GORM.
type userStoreGorm struct {
db *gorm.DB
}
// newUserStore creates a new GORM-backed UserStore.
func newUserStore(database *gorm.DB) UserStore {
return &userStoreGorm{db: database}
}
// Create inserts a new user record.
func (s *userStoreGorm) Create(user *db.User) error {
return s.db.Create(user).Error
}
// GetByID retrieves a user by primary key.
func (s *userStoreGorm) GetByID(id uint) (*db.User, error) {
var user db.User
if err := s.db.Preload("Domain").First(&user, id).Error; err != nil {
return nil, err
}
return &user, nil
}
// GetByUsername retrieves a user by username and domain ID.
func (s *userStoreGorm) GetByUsername(username string, domainID uint) (*db.User, error) {
var user db.User
if err := s.db.Where("username = ? AND domain_id = ?", username, domainID).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// GetByEmail retrieves a user by email address (user@domain format).
func (s *userStoreGorm) GetByEmail(email string) (*db.User, error) {
parts := strings.SplitN(email, "@", 2)
if len(parts) != 2 {
return nil, ErrInvalidEmail
}
username := parts[0]
domainName := parts[1]
var user db.User
if err := s.db.Joins("JOIN domains ON domains.id = users.domain_id").
Where("users.username = ? AND domains.name = ?", username, domainName).
Preload("Domain").
First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// Authenticate verifies an email/password combination and returns the user on success.
func (s *userStoreGorm) Authenticate(email, password string) (*db.User, error) {
user, err := s.GetByEmail(email)
if err != nil {
return nil, ErrInvalidCredentials
}
if !user.IsActive {
return nil, ErrUserInactive
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
return user, nil
}
// AuthenticateLogin 协议层登录:优先按完整邮箱认证;裸用户名(无 @)时
// 按用户名全局查找,仅在唯一归属时接受(多域名同名视为歧义,返回失败,
// 客户端应改用完整邮箱)。密码校验与 IsActive 逻辑与 Authenticate 一致。
func (s *userStoreGorm) AuthenticateLogin(login, password string) (*db.User, error) {
if strings.Contains(login, "@") {
return s.Authenticate(login, password)
}
var users []db.User
if err := s.db.Joins("JOIN domains ON domains.id = users.domain_id").
Where("users.username = ?", login).
Preload("Domain").
Find(&users).Error; err != nil {
return nil, ErrInvalidCredentials
}
if len(users) != 1 {
// 0 个:用户不存在;多个:跨域名同名歧义,要求完整邮箱
return nil, ErrInvalidCredentials
}
user := users[0]
if !user.IsActive {
return nil, ErrUserInactive
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
return &user, nil
}
// LoginExists 判断登录名是否对应系统中的用户:完整邮箱按邮箱查,
// 裸用户名按用户名全局查(存在即算,歧义不影响存在性判定)。
// 仅用于封禁分级(knownUser),不做认证。
func (s *userStoreGorm) LoginExists(login string) bool {
login = strings.TrimSpace(login)
if login == "" {
return false
}
if strings.Contains(login, "@") {
_, err := s.GetByEmail(login)
return err == nil
}
var count int64
if err := s.db.Model(&db.User{}).Where("username = ?", login).Count(&count).Error; err != nil {
return false
}
return count > 0
}
// Update saves changes to an existing user record.
func (s *userStoreGorm) Update(user *db.User) error {
return s.db.Save(user).Error
}
// Delete removes a user by ID (soft delete if supported, hard delete otherwise).
func (s *userStoreGorm) Delete(id uint) error {
return s.db.Delete(&db.User{}, id).Error
}
// List retrieves a paginated list of users for a given domain.
func (s *userStoreGorm) List(domainID uint, page, size int) ([]db.User, int64, error) {
var users []db.User
var total int64
query := s.db.Where("domain_id = ?", domainID)
if err := query.Model(&db.User{}).Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * size
if err := s.db.Preload("Domain").Where("domain_id = ?", domainID).Offset(offset).Limit(size).Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
// UpdateUsedBytes atomically adjusts the UsedBytes field by delta.
func (s *userStoreGorm) UpdateUsedBytes(id uint, delta int64) error {
return s.db.Model(&db.User{}).Where("id = ?", id).
Update("used_bytes", gorm.Expr("used_bytes + ?", delta)).Error
}
// TryReserveQuota atomically reserves delta bytes for a user within quota.
// The reservation is applied (used_bytes incremented) only when it does not
// exceed quota_bytes; otherwise no change is made and false is returned.
func (s *userStoreGorm) TryReserveQuota(userID uint, delta int64) (bool, error) {
if delta <= 0 {
return false, nil
}
res := s.db.Model(&db.User{}).
Where("id = ? AND used_bytes + ? <= quota_bytes", userID, delta).
Update("used_bytes", gorm.Expr("used_bytes + ?", delta))
if res.Error != nil {
return false, res.Error
}
return res.RowsAffected == 1, nil
}
// UpdatePassword updates the password hash for a user and clears the
// must-change-password flag (the user has now set their own password).
func (s *userStoreGorm) UpdatePassword(userID uint, hashedPassword string) error {
return s.db.Model(&db.User{}).Where("id = ?", userID).
Updates(map[string]interface{}{
"password_hash": hashedPassword,
"must_change_password": false,
}).Error
}
// UpdateLanguage 更新用户界面语言偏好。非法值(非 auto/en/zh/ja)回退 auto。
func (s *userStoreGorm) UpdateLanguage(userID uint, lang string) error {
switch lang {
case "auto", "en", "zh", "ja":
default:
lang = "auto"
}
return s.db.Model(&db.User{}).Where("id = ?", userID).
Update("language", lang).Error
}
// ListAll retrieves a paginated list of all users across all domains.
func (s *userStoreGorm) ListAll(page, size int) ([]db.User, int64, error) {
var users []db.User
var total int64
if err := s.db.Model(&db.User{}).Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * size
if err := s.db.Preload("Domain").Offset(offset).Limit(size).Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}