// Copyright 2022 E99p1ant. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. // Modify by tamakyi package db import ( "context" "sort" "strings" "sync" "time" "github.com/pkg/errors" "github.com/rs/xid" "github.com/sirupsen/logrus" "gorm.io/gorm" "gorm.io/gorm/clause" "github.com/tamakyi/TamaBox/internal/conf" ) var Users UsersStore var _ UsersStore = (*users)(nil) type UsersStore interface { Create(ctx context.Context, opts CreateUserOptions) error GetByID(ctx context.Context, id uint) (*User, error) GetByEmail(ctx context.Context, email string) (*User, error) GetByDomain(ctx context.Context, domain string) (*User, error) GetByIDs(ctx context.Context, ids []uint) ([]*User, error) Update(ctx context.Context, id uint, opts UpdateUserOptions) error // 防骚扰设置拆分为两个独立更新:仅允许注册用户(harassment_setting 列)与 // 全局屏蔽词(block_words 列)。两者设置入口现已分处不同面板(屏蔽词在 // 提问/私聊/投票三个面板共用一份),必须分开写,避免单侧保存误清空另一侧。 UpdateHarassmentType(ctx context.Context, id uint, typ HarassmentSettingType) error UpdateBlockWords(ctx context.Context, id uint, words string) error // UpdateModerationStrictness 更新内容审核严格度(lax / standard / strict,空值表示跟随站点默认)。 // 只写 moderation_strictness 列,不影响防骚扰与屏蔽词。 UpdateModerationStrictness(ctx context.Context, id uint, strictness string) error Authenticate(ctx context.Context, email, password string) (*User, error) ChangePassword(ctx context.Context, id uint, oldPassword, newPassword string) error UpdatePassword(ctx context.Context, id uint, newPassword string) error Deactivate(ctx context.Context, id uint) error GetShowInHome(ctx context.Context, page, pageSize int, sort string) ([]*User, int64, error) GetByTag(ctx context.Context, tag string, page, pageSize int, sort string) ([]*User, int64, error) UpdateTags(ctx context.Context, id uint, opts UpdateTagsOptions) error GetRandom(ctx context.Context, limit int) ([]*User, error) GetRandomFresh(ctx context.Context, limit int) ([]*User, error) GetHotTags(ctx context.Context, limit int) ([]string, error) // 全站统计 CountAll(ctx context.Context) (int64, error) UpdatePagePassword(ctx context.Context, id uint, password string) error UpdateAskPassword(ctx context.Context, id uint, password string) error // 私聊设置 UpdateChatSettings(ctx context.Context, id uint, allowChat bool, maxMessages int) error // UpdateAllowChat 仅同步私聊开关(功能管理页统一开关时调用,不影响条数上限) UpdateAllowChat(ctx context.Context, id uint, allowChat bool) error // 拉黑相关 BlockUser(ctx context.Context, userID, blockedUserID uint) error UnblockUser(ctx context.Context, userID, blockedUserID uint) error IsUserBlocked(ctx context.Context, userID, targetID uint) (bool, error) BlockedByUser(ctx context.Context, userID uint, targetIDs []uint) (map[uint]bool, error) BlockedByAnyOf(ctx context.Context, blockerIDs []uint, targetID uint) (map[uint]bool, error) // 每日限制相关 CountTodayMessagesFromUser(ctx context.Context, ownerID, askerID uint) (int64, error) UpdateDailyMessageLimit(ctx context.Context, id uint, limit int) error BanUserAndCleanup(ctx context.Context, userID uint) error } func NewUsersStore(db *gorm.DB) UsersStore { return &users{db} } type users struct { *gorm.DB } type User struct { gorm.Model `json:"-"` UID string `json:"-"` Name string `json:"name"` Password string `json:"-"` Email string `json:"email" gorm:"uniqueIndex:idx_users_email_unique"` Avatar string `json:"avatar"` Domain string `json:"domain" gorm:"uniqueIndex:idx_users_domain_unique"` Background string `json:"background"` Intro string `json:"intro"` Introcolor string `json:"introcolor"` Usernamecolor string `json:"usernamecolor"` Qrcodebackcolor string `json:"qrcodebackcolor"` Qrcodecolor string `json:"qrcodecolor"` BackgroundImage string `json:"backgroundimage"` Dotscale string `json:"dotscale"` Acfunlink string `json:"acfunlink"` Bililink string `json:"bililink"` Weibolink string `json:"weibolink"` Bloglink string `json:"bloglink"` Xiaohongshulink string `json:"xiaohongshulink"` Qrcodepdpcolor string `json:"qrcodepdpcolor"` Backgroundimagealpha string `json:"Backgroundimagealpha"` Notify NotifyType `json:"notify"` HarassmentSetting HarassmentSettingType `json:"harassment_setting"` BlockWords string `json:"-"` // ModerationStrictness 内容审核严格度:lax / standard / strict。 // 空值表示「跟随站点默认」([security] default_moderation_strictness), // 存量账号迁移后即为空值形态 —— 缺省回退 strict(最严档), // 保证引入该字段这一动作不会放宽任何账号的拦截面。 // 取值解释与裁决逻辑见 internal/security/censor/policy.go。 ModerationStrictness string `json:"-" gorm:"column:moderation_strictness;type:varchar(16);default:''"` Tags string `json:"tags" gorm:"type:text"` ShowInHome bool `json:"show_in_home" gorm:"default:false;index"` UseCustomQRColor bool `json:"use_custom_qr_color" gorm:"default:false"` PagePassword string `json:"-" gorm:"column:page_password;type:varchar(255);default:''"` AskPassword string `json:"-" gorm:"column:ask_password;type:varchar(255);default:''"` // 私聊相关 AllowChat bool `json:"allow_chat" gorm:"default:false"` // 每个私聊会话的消息条数上限(用户自设)。default:20 仅是 DB 层兜底; // 应用层创建用户时按 conf.Limits.ChatMaxMessages([limits] 段)显式写入默认值, // 新用户默认即系统硬顶,而非固定 20。 ChatMaxMessages int `json:"chat_max_messages" gorm:"default:20"` // 每日私信条数上限 DailyMessageLimit int `gorm:"default:0" json:"daily_message_limit"` // 界面语言(提问箱页面与后台页面展示语言,取值见 internal/i18n) Language string `gorm:"type:varchar(16);default:''" json:"language"` // PasswordVersion 用于在密码变更后使旧会话失效(会话中保存登录时的版本号)。 PasswordVersion uint `gorm:"default:0" json:"-"` } func (u *User) BeforeCreate(_ *gorm.DB) error { u.UID = xid.New().String() return nil } type NotifyType string const ( NotifyTypeEmail NotifyType = "email" NotifyTypeNone NotifyType = "none" ) type HarassmentSettingType string const ( HarassmentSettingNone HarassmentSettingType = "none" HarassmentSettingTypeRegisterOnly HarassmentSettingType = "register_only" ) func (u *User) EncodePassword() { u.Password = encodePasswordBcrypt(u.Password) } func (u *User) Authenticate(password string) bool { ok, _ := authenticatePassword(u.Password, password) return ok } func (u *User) SetPagePassword(password string) { if password == "" { u.PagePassword = "" } else { u.PagePassword = encodePasswordBcrypt(password) } } func (u *User) CheckPagePassword(password string) bool { ok, _ := authenticatePassword(u.PagePassword, password) return ok } func (u *User) SetAskPassword(password string) { if password == "" { u.AskPassword = "" } else { u.AskPassword = encodePasswordBcrypt(password) } } func (u *User) CheckAskPassword(password string) bool { ok, _ := authenticatePassword(u.AskPassword, password) return ok } type CreateUserOptions struct { Name string Password string Email string Avatar string Domain string Background string Intro string Dotscale string Qrcodebackcolor string Qrcodecolor string Qrcodepdpcolor string BackgroundImage string Backgroundimagealpha string Usernamecolor string Introcolor string Acfunlink string Bililink string Weibolink string Xiaohongshulink string Bloglink string Tags string ShowInHome bool Language string // 界面语言(i18n 规范代码,如 zh-CN / zh-TW / en / ja) } var ( ErrUserNotExists = errors.New("账号不存在") ErrBadCredential = errors.New("邮箱或密码错误") ErrDuplicateEmail = errors.New("这个邮箱已经注册过账号了!") ErrDuplicateDomain = errors.New("个性域名重复了,换一个吧~") ) func (db *users) Create(ctx context.Context, opts CreateUserOptions) error { if err := db.validate(ctx, opts); err != nil { return err } newUser := &User{ Name: opts.Name, Password: opts.Password, Email: opts.Email, Avatar: opts.Avatar, Domain: opts.Domain, Background: opts.Background, Intro: opts.Intro, Qrcodebackcolor: opts.Qrcodebackcolor, Qrcodecolor: opts.Qrcodecolor, Dotscale: opts.Dotscale, BackgroundImage: opts.BackgroundImage, Backgroundimagealpha: opts.Backgroundimagealpha, Qrcodepdpcolor: opts.Qrcodepdpcolor, Notify: NotifyTypeEmail, Usernamecolor: opts.Usernamecolor, Introcolor: opts.Introcolor, Acfunlink: opts.Acfunlink, Bililink: opts.Bililink, Weibolink: opts.Weibolink, Xiaohongshulink: opts.Xiaohongshulink, Bloglink: opts.Bloglink, Tags: opts.Tags, ShowInHome: opts.ShowInHome, Language: opts.Language, // 私聊会话条数上限:新用户默认取系统硬顶(conf.Limits.ChatMaxMessages, // [limits] 配置段),用户可在 1..硬顶 之间下调。不要回退到 gorm default:20。 ChatMaxMessages: conf.Limits.ChatMaxMessages, } newUser.EncodePassword() if err := db.WithContext(ctx).Create(newUser).Error; err != nil { // 并发同值双过(TOCTOU):唯一索引兜底会返回重复键错误。 // 此时重新执行校验,给出与主动校验一致的友好错误,而非暴露底层数据库错误。 if verr := db.validate(ctx, opts); verr != nil { return verr } return errors.Wrap(err, "create user") } return nil } func (db *users) getBy(ctx context.Context, where string, args ...interface{}) (*User, error) { var user User if err := db.WithContext(ctx).Where(where, args...).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrUserNotExists } return nil, errors.Wrap(err, "get user") } return &user, nil } func (db *users) GetByID(ctx context.Context, id uint) (*User, error) { return db.getBy(ctx, "id = ?", id) } func (db *users) GetByEmail(ctx context.Context, email string) (*User, error) { return db.getBy(ctx, "email = ?", email) } func (db *users) GetByDomain(ctx context.Context, domain string) (*User, error) { return db.getBy(ctx, "domain = ?", domain) } // GetByIDs 批量按 ID 查询用户,返回顺序与数据库返回一致(不保证与入参顺序一致), // 用于批量关联查询,避免循环 GetByID 造成 N+1。 func (db *users) GetByIDs(ctx context.Context, ids []uint) ([]*User, error) { if len(ids) == 0 { return []*User{}, nil } var users []*User if err := db.WithContext(ctx).Where("id IN ?", ids).Find(&users).Error; err != nil { return nil, errors.Wrap(err, "get users by ids") } return users, nil } type UpdateUserOptions struct { Name string Avatar string Background string Intro string Qrcodebackcolor string Qrcodecolor string Dotscale string BackgroundImage string Backgroundimagealpha string Qrcodepdpcolor string Usernamecolor string Introcolor string Acfunlink string Bililink string Weibolink string Xiaohongshulink string Bloglink string UseCustomQRColor bool Notify NotifyType Language string } func (db *users) Update(ctx context.Context, id uint, opts UpdateUserOptions) error { _, err := db.GetByID(ctx, id) if err != nil { return errors.Wrap(err, "get user by id") } switch opts.Notify { case NotifyTypeEmail, NotifyTypeNone: default: return errors.Errorf("unexpected notify type: %q", opts.Notify) } updates := map[string]interface{}{ "Name": opts.Name, "Avatar": opts.Avatar, "Background": opts.Background, "Intro": opts.Intro, "Qrcodebackcolor": opts.Qrcodebackcolor, "Qrcodecolor": opts.Qrcodecolor, "Dotscale": opts.Dotscale, "BackgroundImage": opts.BackgroundImage, "Backgroundimagealpha": opts.Backgroundimagealpha, "Qrcodepdpcolor": opts.Qrcodepdpcolor, "Usernamecolor": opts.Usernamecolor, "Introcolor": opts.Introcolor, "Acfunlink": opts.Acfunlink, "Bililink": opts.Bililink, "Weibolink": opts.Weibolink, "Xiaohongshulink": opts.Xiaohongshulink, "Bloglink": opts.Bloglink, "UseCustomQRColor": opts.UseCustomQRColor, "Notify": opts.Notify, "Language": opts.Language, } if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(updates).Error; err != nil { return errors.Wrap(err, "update user") } return nil } // UpdateHarassmentType 仅更新骚扰防护类型(none / register_only)。 // 只写 harassment_setting 列,不动 block_words,避免与全局屏蔽词的独立保存互相覆盖。 func (db *users) UpdateHarassmentType(ctx context.Context, id uint, typ HarassmentSettingType) error { switch typ { case HarassmentSettingNone, HarassmentSettingTypeRegisterOnly: default: return errors.Errorf("unexpected harassment setting type: %q", typ) } if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{ "HarassmentSetting": typ, }).Error; err != nil { return errors.Wrap(err, "update user") } return nil } // UpdateBlockWords 仅更新全局屏蔽词表(逗号分隔,保存前已去重/去空白/限额)。 // 只写 block_words 列,不动 harassment_setting。 func (db *users) UpdateBlockWords(ctx context.Context, id uint, words string) error { if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{ "BlockWords": words, }).Error; err != nil { return errors.Wrap(err, "update user") } return nil } // UpdateModerationStrictness 仅更新内容审核严格度。 // 只写 moderation_strictness 列,不动 harassment_setting 与 block_words, // 避免三个独立设置面板互相覆盖。 // // 取值合法性由上层(route/user)在写入前校验。此处不再二次校验是有意为之: // 本包不能 import internal/security/censor(后者要读 censor_logs,反向依赖本包, // 会形成 import 环)。即便绕过上层写入脏值也不会有安全问题——解析层的 // censor.ParseStrictness 对未知取值回退「严格」档,方向是保守的(见 policy.go)。 func (db *users) UpdateModerationStrictness(ctx context.Context, id uint, strictness string) error { if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{ "ModerationStrictness": strictness, }).Error; err != nil { return errors.Wrap(err, "update user") } return nil } func (db *users) Authenticate(ctx context.Context, email, password string) (*User, error) { u, err := db.GetByEmail(ctx, email) if err != nil { return nil, ErrBadCredential } ok, newHash := authenticatePassword(u.Password, password) if !ok { return nil, ErrBadCredential } // 透明升级:如果密码是旧的 HMAC-SHA1 格式,自动升级为 bcrypt。 if newHash != "" { if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", u.ID).Update("password", newHash).Error; err != nil { logrus.WithContext(ctx).WithError(err).Warn("Failed to auto-upgrade password to bcrypt") } else { u.Password = newHash } } return u, nil } func (db *users) ChangePassword(ctx context.Context, id uint, oldPassword, newPassword string) error { u, err := db.GetByID(ctx, id) if err != nil { return errors.Wrap(err, "get user by id") } ok, upgradeHash := authenticatePassword(u.Password, oldPassword) if !ok { return ErrBadCredential } // 如果旧密码触发升级,先升级 if upgradeHash != "" { u.Password = upgradeHash } u.Password = newPassword u.EncodePassword() if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", u.ID).Updates(map[string]interface{}{ "password": u.Password, "password_version": gorm.Expr("password_version + 1"), }).Error; err != nil { return errors.Wrap(err, "change password") } return nil } func (db *users) UpdatePassword(ctx context.Context, id uint, newPassword string) error { u, err := db.GetByID(ctx, id) if err != nil { return errors.Wrap(err, "get user by id") } u.Password = newPassword u.EncodePassword() if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", u.ID).Updates(map[string]interface{}{ "password": u.Password, "password_version": gorm.Expr("password_version + 1"), }).Error; err != nil { return errors.Wrap(err, "change password") } return nil } func (db *users) Deactivate(ctx context.Context, id uint) error { u, err := db.GetByID(ctx, id) if err != nil { return errors.Wrap(err, "get user by id") } if err := db.WithContext(ctx).Model(&User{}).Delete("id = ?", u.ID).Error; err != nil { return errors.Wrap(err, "delete user") } return nil } func (db *users) validate(ctx context.Context, opts CreateUserOptions) error { if err := db.WithContext(ctx).Model(&User{}).Where("email = ?", opts.Email).First(&User{}).Error; err != nil { if err != gorm.ErrRecordNotFound { return errors.Wrap(err, "validate email") } } else { return ErrDuplicateEmail } if err := db.WithContext(ctx).Model(&User{}).Where("domain = ?", opts.Domain).First(&User{}).Error; err != nil { if err != gorm.ErrRecordNotFound { return errors.Wrap(err, "validate name") } } else { return ErrDuplicateDomain } return nil } type UpdateTagsOptions struct { Tags string ShowInHome bool } func (db *users) UpdateTags(ctx context.Context, id uint, opts UpdateTagsOptions) error { if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{ "Tags": opts.Tags, "ShowInHome": opts.ShowInHome, }).Error; err != nil { return errors.Wrap(err, "update user tags") } return nil } func (db *users) GetShowInHome(ctx context.Context, page, pageSize int, sort string) ([]*User, int64, error) { var users []*User var count int64 if err := db.WithContext(ctx).Model(&User{}).Where("show_in_home = ?", true).Count(&count).Error; err != nil { return nil, 0, errors.Wrap(err, "count show in home users") } query := db.WithContext(ctx).Where("show_in_home = ?", true) switch sort { case "oldest": query = query.Order("created_at asc") default: query = query.Order("created_at desc") } offset := (page - 1) * pageSize if err := query.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil { return nil, 0, errors.Wrap(err, "get show in home users") } return users, count, nil } func (db *users) GetByTag(ctx context.Context, tag string, page, pageSize int, sort string) ([]*User, int64, error) { var users []*User var count int64 // tags 以逗号分隔存储;按数据库类型使用不同的标签匹配语法: // PostgreSQL: string_to_array + ANY;MySQL/SQLite: FIND_IN_SET var tagCond string if conf.Database.Type == "postgres" { tagCond = "? = ANY(string_to_array(tags, ','))" } else { tagCond = "FIND_IN_SET(?, tags)" } baseQuery := db.WithContext(ctx).Model(&User{}).Where("show_in_home = ? AND tags IS NOT NULL AND "+tagCond, true, tag) if err := baseQuery.Count(&count).Error; err != nil { return nil, 0, errors.Wrap(err, "count users by tag") } query := baseQuery switch sort { case "oldest": query = query.Order("created_at asc") default: query = query.Order("created_at desc") } offset := (page - 1) * pageSize if err := query.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil { return nil, 0, errors.Wrap(err, "get users by tag") } return users, count, nil } // 随机推荐列表缓存:ORDER BY RANDOM() 在大表上会全表扫描+排序, // explore 页面高频调用,用 30 秒短缓存吸收并发,成本是列表最多滞后 30 秒。 // 缓存池大小 24(4 倍展示数),初始页面取前 6 个, // "换一批"用 GetRandomFresh 绕过缓存,确保每次返回不同结果。 var ( randomUsersCacheMu sync.Mutex randomUsersCache []*User randomUsersCacheTime time.Time ) const ( randomUsersCacheTTL = 30 * time.Second randomCachePoolSize = 24 ) // GetRandom 获取随机用户(带缓存)。缓存池大小 24,返回前 limit 个。 // 返回的是切片副本,调用方修改返回值不会污染缓存。 func (db *users) GetRandom(ctx context.Context, limit int) ([]*User, error) { randomUsersCacheMu.Lock() if randomUsersCache != nil && time.Since(randomUsersCacheTime) < randomUsersCacheTTL { cached := copyUsers(randomUsersCache) randomUsersCacheMu.Unlock() if limit < len(cached) { cached = cached[:limit] } return cached, nil } randomUsersCacheMu.Unlock() // 缓存未命中,查询一个较大的池以覆盖后续请求 users, err := db.getRandomUsers(ctx, randomCachePoolSize) if err != nil { return nil, err } // 更新缓存(缓存持有的是 DB 查询结果,与返回给调用方的副本分离) randomUsersCacheMu.Lock() randomUsersCache = users randomUsersCacheTime = time.Now() randomUsersCacheMu.Unlock() copied := copyUsers(users) if limit < len(copied) { copied = copied[:limit] } return copied, nil } // copyUsers 深拷贝 User 切片,避免调用方修改(如给 User 字段赋值)污染缓存。 func copyUsers(src []*User) []*User { dst := make([]*User, len(src)) for i, u := range src { if u == nil { continue } cp := *u dst[i] = &cp } return dst } // GetRandomFresh 获取随机用户(绕过缓存),用于"换一批"按钮。 func (db *users) GetRandomFresh(ctx context.Context, limit int) ([]*User, error) { return db.getRandomUsers(ctx, limit) } func (db *users) getRandomUsers(ctx context.Context, limit int) ([]*User, error) { var users []*User randomFunc := "RAND()" // MySQL / SQLite 默认 if conf.Database.Type == "postgres" { randomFunc = "RANDOM()" } if err := db.WithContext(ctx). Where("show_in_home = ?", true). Order(randomFunc). Limit(limit). Find(&users).Error; err != nil { return nil, errors.Wrap(err, "get random users") } return users, nil } // 热标签缓存:GetHotTags 每次 explore 都全表 Pluck tags 再内存聚合, // 标签变化不频繁,用 60 秒进程内缓存吸收重复计算,避免每次翻页都触发全表扫描。 var ( hotTagsCacheMu sync.Mutex hotTagsCache []string hotTagsCacheTime time.Time ) const hotTagsCacheTTL = 60 * time.Second // CountAll 返回全站注册用户总数(首页统计用,不按单个用户过滤)。 func (db *users) CountAll(ctx context.Context) (int64, error) { var count int64 if err := db.WithContext(ctx).Model(&User{}).Count(&count).Error; err != nil { return 0, errors.Wrap(err, "count all users") } return count, nil } // GetHotTags 统计所有展示在首页的用户标签,返回出现次数最多的前 limit 个标签。 // 标签存于 users.tags(逗号分隔),此处取出后在内存中聚合统计,结果带短缓存。 func (db *users) GetHotTags(ctx context.Context, limit int) ([]string, error) { if limit <= 0 { limit = 10 } hotTagsCacheMu.Lock() if hotTagsCache != nil && time.Since(hotTagsCacheTime) < hotTagsCacheTTL { cached := append([]string(nil), hotTagsCache...) hotTagsCacheMu.Unlock() if limit < len(cached) { cached = cached[:limit] } return cached, nil } hotTagsCacheMu.Unlock() var tagsList []string if err := db.WithContext(ctx). Model(&User{}). Where("show_in_home = ? AND tags IS NOT NULL AND tags != ''", true). Pluck("tags", &tagsList).Error; err != nil { return nil, errors.Wrap(err, "get user tags for hot tags") } counts := make(map[string]int) for _, tags := range tagsList { for _, tag := range strings.Split(tags, ",") { tag = strings.TrimSpace(tag) if tag == "" { continue } counts[tag]++ } } // 按出现次数降序 hot := make([]string, 0, len(counts)) for tag := range counts { hot = append(hot, tag) } sort.Slice(hot, func(i, j int) bool { if counts[hot[i]] == counts[hot[j]] { return hot[i] < hot[j] } return counts[hot[i]] > counts[hot[j]] }) // 缓存完整排序结果(不按 limit 截断),供不同 limit 的调用方复用。 hotTagsCacheMu.Lock() hotTagsCache = append([]string(nil), hot...) hotTagsCacheTime = time.Now() hotTagsCacheMu.Unlock() if len(hot) > limit { hot = hot[:limit] } return hot, nil } func (db *users) UpdatePagePassword(ctx context.Context, id uint, password string) error { user, err := db.GetByID(ctx, id) if err != nil { return err } user.SetPagePassword(password) return db.WithContext(ctx).Model(user).Update("page_password", user.PagePassword).Error } func (db *users) UpdateAskPassword(ctx context.Context, id uint, password string) error { user, err := db.GetByID(ctx, id) if err != nil { return err } user.SetAskPassword(password) return db.WithContext(ctx).Model(user).Update("ask_password", user.AskPassword).Error } // 新增私聊设置 func (db *users) UpdateChatSettings(ctx context.Context, id uint, allowChat bool, maxMessages int) error { return db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{ "allow_chat": allowChat, "chat_max_messages": maxMessages, }).Error } // UpdateAllowChat 仅更新 users.allow_chat,用于与 feature_controls 的私聊开关保持同步 // (users.allow_chat 仍被邮件/导出等既有逻辑读取,保留双向一致)。 func (db *users) UpdateAllowChat(ctx context.Context, id uint, allowChat bool) error { return db.WithContext(ctx).Model(&User{}).Where("id = ?", id). Update("allow_chat", allowChat).Error } // ---------- 拉黑与每日限制 ---------- // BlockUser 拉黑用户。使用 (user_id, blocked_id) 唯一索引 + INSERT ... ON CONFLICT DO NOTHING, // 幂等且并发安全(重复/并发拉黑不会互相覆盖丢失)。 func (db *users) BlockUser(ctx context.Context, userID, blockedUserID uint) error { if userID == blockedUserID { return errors.New("cannot block self") } block := &UserBlock{UserID: userID, BlockedID: blockedUserID} if err := db.WithContext(ctx). Clauses(clause.OnConflict{DoNothing: true}). Create(block).Error; err != nil { return errors.Wrap(err, "block user") } return nil } // UnblockUser 取消拉黑(物理删除关联行)。 func (db *users) UnblockUser(ctx context.Context, userID, blockedUserID uint) error { if err := db.WithContext(ctx). Where("user_id = ? AND blocked_id = ?", userID, blockedUserID). Delete(&UserBlock{}).Error; err != nil { return errors.Wrap(err, "unblock user") } return nil } // IsUserBlocked 检查 userID 是否拉黑了 targetID(走唯一索引查询)。 func (db *users) IsUserBlocked(ctx context.Context, userID, targetID uint) (bool, error) { var count int64 if err := db.WithContext(ctx).Model(&UserBlock{}). Where("user_id = ? AND blocked_id = ?", userID, targetID). Count(&count).Error; err != nil { return false, errors.Wrap(err, "check block status") } return count > 0, nil } // BlockedByUser 批量判断:返回 targetIDs 中被 userID 拉黑的子集(map[targetID]bool)。 func (db *users) BlockedByUser(ctx context.Context, userID uint, targetIDs []uint) (map[uint]bool, error) { result := make(map[uint]bool, len(targetIDs)) if len(targetIDs) == 0 { return result, nil } var blocks []UserBlock if err := db.WithContext(ctx). Where("user_id = ? AND blocked_id IN ?", userID, targetIDs). Find(&blocks).Error; err != nil { return nil, errors.Wrap(err, "get blocked users") } for _, b := range blocks { result[b.BlockedID] = true } return result, nil } // BlockedByAnyOf 批量判断:返回 blockerIDs 中拉黑了 targetID 的子集(map[blockerID]bool)。 func (db *users) BlockedByAnyOf(ctx context.Context, blockerIDs []uint, targetID uint) (map[uint]bool, error) { result := make(map[uint]bool, len(blockerIDs)) if len(blockerIDs) == 0 { return result, nil } var blocks []UserBlock if err := db.WithContext(ctx). Where("user_id IN ? AND blocked_id = ?", blockerIDs, targetID). Find(&blocks).Error; err != nil { return nil, errors.Wrap(err, "get blockers") } for _, b := range blocks { result[b.UserID] = true } return result, nil } // CountTodayMessagesFromUser 统计某提问者当天给某被提问者发送的私信总条数。 // 使用应用进程所在时区(time.Local)的"当日 00:00"作为窗口起点, // 而非数据库服务器的 CURRENT_DATE,避免 DB 时区与应用时区不一致导致每日上限窗口偏移。 func (db *users) CountTodayMessagesFromUser(ctx context.Context, ownerID, askerID uint) (int64, error) { now := time.Now() startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) var count int64 err := db.WithContext(ctx).Table("messages"). Joins("JOIN conversations ON conversations.id = messages.conversation_id"). Where("conversations.owner_id = ? AND messages.sender_id = ?", ownerID, askerID). Where("messages.created_at >= ?", startOfDay). Count(&count).Error return count, err } // UpdateDailyMessageLimit 更新每日限制 func (db *users) UpdateDailyMessageLimit(ctx context.Context, id uint, limit int) error { return db.WithContext(ctx).Model(&User{}).Where("id = ?", id). Update("daily_message_limit", limit).Error } // BanUserAndCleanup 封禁用户并连带清理其全部数据(提问、追问、私聊、消息、图片关联), // 最后物理删除用户记录。供管理 CLI 命令(tamabox ban)使用。 // // 注意:该操作不可恢复,执行前请确认目标账号。 func (db *users) BanUserAndCleanup(ctx context.Context, userID uint) error { return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 追问:属于该用户提问下的追问 if err := tx.WithContext(ctx). Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID). Delete(&FollowUp{}).Error; err != nil { return errors.Wrap(err, "delete follow ups of banned user") } // 消息:属于该用户作为 owner 或 asker 的会话 if err := tx.WithContext(ctx). Where("conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?)", userID, userID). Delete(&Message{}).Error; err != nil { return errors.Wrap(err, "delete messages of banned user") } // 消息图片 if err := tx.WithContext(ctx). Where("message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))", userID, userID). Delete(&MessageImage{}).Error; err != nil { return errors.Wrap(err, "delete message images of banned user") } // 私聊会话 if err := tx.WithContext(ctx). Where("owner_id = ? OR asker_id = ?", userID, userID). Delete(&Conversation{}).Error; err != nil { return errors.Wrap(err, "delete conversations of banned user") } // 提问-图片关联(仅删除关联记录;UploadImage 本体为通用资源,保留) if err := tx.WithContext(ctx). Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID). Delete(&UploadImageQuestion{}).Error; err != nil { return errors.Wrap(err, "delete upload image links of banned user") } // 提问 if err := tx.WithContext(ctx). Where("user_id = ?", userID). Delete(&Question{}).Error; err != nil { return errors.Wrap(err, "delete questions of banned user") } // 拉黑关系(该用户拉黑别人,或被别人拉黑) if err := tx.WithContext(ctx). Where("user_id = ? OR blocked_id = ?", userID, userID). Delete(&UserBlock{}).Error; err != nil { return errors.Wrap(err, "delete user blocks of banned user") } // 功能开关配置(提问 / 私聊 / 投票) if err := tx.WithContext(ctx).Unscoped(). Where("user_id = ?", userID). Delete(&FeatureControl{}).Error; err != nil { return errors.Wrap(err, "delete feature controls of banned user") } // 物理删除用户 if err := tx.WithContext(ctx).Unscoped().Delete(&User{}, userID).Error; err != nil { return errors.Wrap(err, "delete banned user") } return nil }) }