fix login error

This commit is contained in:
tamakyi
2026-09-15 13:10:57 +08:00
parent 0e0c16f786
commit 5ee2dc1540
14 changed files with 801 additions and 68 deletions
+148 -68
View File
@@ -71,6 +71,8 @@ type UsersStore interface {
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 {
@@ -226,6 +228,11 @@ var (
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 {
@@ -507,38 +514,94 @@ func (db *users) UpdatePassword(ctx context.Context, id uint, newPassword string
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")
}
if err := db.WithContext(ctx).Model(&User{}).Delete("id = ?", u.ID).Error; err != nil {
return errors.Wrap(err, "delete user")
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 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 {
if taken, err := db.fieldTaken(ctx, "email", opts.Email, false); err != nil {
return errors.Wrap(err, "validate email")
} else if taken {
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 {
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
@@ -902,68 +965,85 @@ func (db *users) UpdateDailyMessageLimit(ctx context.Context, id uint, limit int
// 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 := 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")
if err := purgeUserContent(ctx, tx, userID); err != nil {
return err
}
// 消息:属于该用户作为 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 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
}