// 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 // Reactivate 恢复被自助停用的账号(内容已清空),供站长核验身份后调用 Reactivate(ctx context.Context, id 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:"-" 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" gorm:"default:'none'"` BlockWords string `json:"-"` // 防骚扰设置取值 none / register_only(见下方常量)。历史账号此列为空串(建表时无默认值), // 与 none 语义等价但取值不同,会导致「按 == none 判断」的隐性漏判 → 已补 schema 默认值、 // Create 显式写入、启动期归一历史空值(见 db.Init 的 normalizeHarassmentSetting)。 // // 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("个性域名重复了,换一个吧~") // 已停用(软删除)账号仍占用 email / domain 唯一索引:gorm 的普通查重看不见它, // 必须 Unscoped 才能识别,否则会退化成「INSERT 撞唯一索引 → 500」。 ErrDeactivatedEmail = errors.New("该邮箱对应的账号已停用") ErrDeactivatedDomain = 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, HarassmentSetting: HarassmentSettingNone, 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 } // normalizeHarassmentSetting 把 users.harassment_setting 的历史空值(空字符串)归一为 none。 // 空串与 none 语义等价(均表示不做限制),归一后取值唯一,避免将来按 == none 判断时漏判。 // 幂等:无空值时不做写入;失败由调用方告警(不阻断启动)。 func normalizeHarassmentSetting(db *gorm.DB) error { res := db.Model(&User{}). Where("harassment_setting IS NULL OR harassment_setting = ''"). Update("harassment_setting", HarassmentSettingNone) if res.Error != nil { return errors.Wrap(res.Error, "normalize harassment setting") } if res.RowsAffected > 0 { logrus.WithField("count", res.RowsAffected). Info("防骚扰设置已归一:历史空值已置为 none") } 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 } // Deactivate 自助停用账号:删除该账号的全部内容数据,保留一行「身份占位」(软删除)。 // // 为什么保留用户行(软删除)而不是物理删除: // users 的 email / domain 是唯一索引。若连行一起删掉,这个邮箱/域名就能被任何人立刻重新注册; // 而停用的语义是「该身份归原主所有」——留占位既是防止他人抢占,也让站长核验后能把账号恢复成空账号。 // ⚠️ 代价是普通查重看不见这一行,注册侧必须用 Unscoped 识别(见 validate 与 ErrDeactivated*), // 否则会退化成「查重放行、INSERT 撞唯一索引 → 500」。 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") } return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := purgeUserContent(ctx, tx, u.ID); err != nil { return err } // 内容已清空,用户行只作身份占位保留 → 软删除(写 deleted_at) if err := tx.WithContext(ctx).Delete(&User{}, u.ID).Error; err != nil { return errors.Wrap(err, "deactivate user") } return nil }) } // Reactivate 恢复被自助停用的账号(内容已被清空的空账号),供站长核验身份后调用。 // 密码不会被重置,若原主已忘记密码,可再用 UpdatePassword 另行处理。 func (db *users) Reactivate(ctx context.Context, id uint) error { result := db.WithContext(ctx).Unscoped().Model(&User{}). Where("id = ? AND deleted_at IS NOT NULL", id). Update("deleted_at", nil) if result.Error != nil { return errors.Wrap(result.Error, "reactivate user") } if result.RowsAffected == 0 { return ErrUserNotExists } return nil } // validate 检查邮箱与个性域名是否可用。 // // 分两轮:先看活跃账号(普通占用),再看「已停用」的软删除占位行。 // 第二轮必须 Unscoped —— 停用会保留用户行作为身份占位,普通查询看不见它, // 但 email / domain 的唯一索引仍会拦下 INSERT,于是表现为「查重放行、插入 500」。 func (db *users) validate(ctx context.Context, opts CreateUserOptions) error { if taken, err := db.fieldTaken(ctx, "email", opts.Email, false); err != nil { return errors.Wrap(err, "validate email") } else if taken { return ErrDuplicateEmail } if taken, err := db.fieldTaken(ctx, "domain", opts.Domain, false); err != nil { return errors.Wrap(err, "validate domain") } else if taken { return ErrDuplicateDomain } if taken, err := db.fieldTaken(ctx, "email", opts.Email, true); err != nil { return errors.Wrap(err, "validate deactivated email") } else if taken { return ErrDeactivatedEmail } if taken, err := db.fieldTaken(ctx, "domain", opts.Domain, true); err != nil { return errors.Wrap(err, "validate deactivated domain") } else if taken { return ErrDeactivatedDomain } return nil } // fieldTaken 判断某个唯一字段是否已被占用。 // includeDeleted 为 true 时连已停用(软删除)的占位行一起计入。 // column 只允许由调用方传入字面量(email / domain),不接受外部输入。 func (db *users) fieldTaken(ctx context.Context, column, value string, includeDeleted bool) (bool, error) { query := db.WithContext(ctx).Model(&User{}) if includeDeleted { query = query.Unscoped() } var count int64 if err := query.Where(column+" = ?", value).Count(&count).Error; err != nil { return false, err } return count > 0, 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)使用。 // // 与自助停用(Deactivate)的区别只有一处:封禁连用户行一起物理删除, // 因此该邮箱/域名会被彻底释放、可被任何人重新注册。 // // 注意:该操作不可恢复,执行前请确认目标账号。 func (db *users) BanUserAndCleanup(ctx context.Context, userID uint) error { return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := purgeUserContent(ctx, tx, userID); err != nil { return err } if err := tx.WithContext(ctx).Unscoped().Delete(&User{}, userID).Error; err != nil { return errors.Wrap(err, "delete user") } return nil }) } // purgeUserContent 物理删除某用户的全部内容数据:追问、消息与消息图片、私聊会话、 // 提问-图片关联、提问、拉黑关系、功能开关。**不含用户行本身**(由调用方决定是软删除还是物理删除)。 // // 全部使用 Unscoped 物理删除而非软删除:这些表多为软删除模型,而部分表的唯一索引 // 不含 deleted_at(如 conversations.token),软删除会让被删行永久占据唯一槽位。 // // ⚠️ 每条语句都必须经 tx.WithContext(ctx) 取新句柄:事务句柄的 clone 为 0, // 直接复用会把上一条语句的 Where 条件串到下一条上(曾把 messages 的条件串进 follow_ups, // 报「字段 conversation_id 不存在」)。 func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error { // 追问:属于该用户提问下的追问 if err := tx.WithContext(ctx).Unscoped(). 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 user") } // 消息:属于该用户作为 owner 或 asker 的会话 if err := tx.WithContext(ctx).Unscoped(). 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 user") } // 消息图片 if err := tx.WithContext(ctx).Unscoped(). 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 user") } // 私聊会话 if err := tx.WithContext(ctx).Unscoped(). Where("owner_id = ? OR asker_id = ?", userID, userID). Delete(&Conversation{}).Error; err != nil { return errors.Wrap(err, "delete conversations of user") } // 提问-图片关联(仅删除关联记录;UploadImage 本体为通用资源,保留) if err := tx.WithContext(ctx).Unscoped(). 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 user") } // 提问 if err := tx.WithContext(ctx).Unscoped(). Where("user_id = ?", userID). Delete(&Question{}).Error; err != nil { return errors.Wrap(err, "delete questions of user") } // 拉黑关系(该用户拉黑别人,或被别人拉黑) if err := tx.WithContext(ctx).Unscoped(). Where("user_id = ? OR blocked_id = ?", userID, userID). Delete(&UserBlock{}).Error; err != nil { return errors.Wrap(err, "delete user blocks of user") } // 功能开关配置(提问 / 私聊 / 投票) if err := tx.WithContext(ctx).Unscoped(). Where("user_id = ?", userID). Delete(&FeatureControl{}).Error; err != nil { return errors.Wrap(err, "delete feature controls of user") } return nil }