1065 lines
39 KiB
Go
1065 lines
39 KiB
Go
// 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)
|
||
query = query.Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx)))
|
||
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)
|
||
baseQuery = baseQuery.Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx)))
|
||
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).
|
||
Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx))).
|
||
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
|
||
}
|
||
|
||
// activeRestrictionsSubQuery 返回「当前处于生效中的处置」的用户 ID 子查询。
|
||
//
|
||
// 用于把被处置的箱主从首页 / 探索 / 标签检索 / 随机位摘除,即 L1「停止分发」的曝光部分。
|
||
// 用子查询而不是先查一遍 ID 列表:这三处都在列表热路径上,多一次往返没有收益;
|
||
// 没有处置记录时子查询返回空集,正常路径不产生额外代价。
|
||
// 注意 GetRandom 有 30 秒缓存,处置后随机位最多滞后 30 秒生效(其余入口即时生效)。
|
||
func activeRestrictionsSubQuery(tx *gorm.DB) *gorm.DB {
|
||
return tx.Table("user_restrictions").Select("user_id").
|
||
Where("revoked_at IS NULL").
|
||
Where("expires_at > ?", time.Now())
|
||
}
|