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
}
+115
View File
@@ -6,6 +6,7 @@ package db
import (
"context"
"errors"
"testing"
"time"
@@ -470,3 +471,117 @@ func TestHarassmentSettingNormalize(t *testing.T) {
require.Nil(t, err)
require.Equal(t, HarassmentSettingNone, got.HarassmentSetting, "历史空值应被归一为 none")
}
// TestDeactivatePurgesUser 覆盖自助停用的删除语义。
//
// 停用 = 清空内容 + 保留用户行作「身份占位」(软删除),因此:
// - 该账号的提问等内容必须被物理删除;
// - 用户行必须仍在(Unscoped 可见,deleted_at 有效),邮箱/域名继续被占用 → 防止他人抢占;
// - 用同一邮箱/域名重新注册必须得到「账号已停用」的明确错误,而不是底层的唯一键 500;
// - 站长可通过 Reactivate 把账号恢复成空账号。
func TestDeactivatePurgesUser(t *testing.T) {
db, cleanup := newTestDB(t)
defer func() { _ = cleanup("users", "questions", "feature_controls") }()
ctx := context.Background()
usersStore := NewUsersStore(db)
questionsStore := NewQuestionsStore(db)
opts := CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "purge",
Background: "background.png",
Intro: "Be cool, but also be warm.",
}
require.Nil(t, usersStore.Create(ctx, opts))
user, err := usersStore.GetByEmail(ctx, opts.Email)
require.Nil(t, err)
_, err = questionsStore.Create(ctx, CreateQuestionOptions{UserID: user.ID, Content: "hello"})
require.Nil(t, err)
require.Nil(t, usersStore.Deactivate(ctx, user.ID))
var n int64
// 身份占位必须保留(Unscoped 可见),且处于软删除状态
var placeholder User
require.Nil(t, db.WithContext(ctx).Unscoped().Where("email = ?", opts.Email).First(&placeholder).Error)
require.True(t, placeholder.DeletedAt.Valid, "停用后用户行应保留为软删除的身份占位")
require.Equal(t, user.ID, placeholder.ID)
require.Nil(t, db.WithContext(ctx).Unscoped().Model(&Question{}).Where("user_id = ?", user.ID).Count(&n).Error)
require.EqualValues(t, 0, n, "停用后该用户的提问应被物理删除")
// 普通查询已看不见该占位行
_, err = usersStore.GetByEmail(ctx, opts.Email)
require.Equal(t, ErrUserNotExists, err)
// 同一邮箱 / 域名重新注册:给出明确的「已停用」错误,而不是底层唯一键冲突
err = usersStore.Create(ctx, opts)
require.True(t, errors.Is(err, ErrDeactivatedEmail), "同邮箱重注册应返回 ErrDeactivatedEmail,实际: %v", err)
require.False(t, errors.Is(err, ErrDuplicateEmail), "不应退化成普通重复邮箱错误")
err = usersStore.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: opts.Domain,
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.True(t, errors.Is(err, ErrDeactivatedDomain), "同域名重注册应返回 ErrDeactivatedDomain,实际: %v", err)
// 站长恢复:占位行清掉 deleted_at,账号重新可见
require.Nil(t, usersStore.Reactivate(ctx, user.ID))
restored, err := usersStore.GetByEmail(ctx, opts.Email)
require.Nil(t, err)
require.Equal(t, user.ID, restored.ID)
require.False(t, restored.DeletedAt.Valid)
// 恢复后该邮箱已被活跃账号占用 → 普通重复错误
require.True(t, errors.Is(usersStore.Create(ctx, opts), ErrDuplicateEmail))
// 未被停用的账号无法重复恢复
require.Equal(t, ErrUserNotExists, usersStore.Reactivate(ctx, user.ID))
}
// TestBanUserAndCleanupRemovesRow 封禁与停用的唯一差别:封禁连用户行一起物理删除,
// 该邮箱/域名被彻底释放。
func TestBanUserAndCleanupRemovesRow(t *testing.T) {
db, cleanup := newTestDB(t)
defer func() { _ = cleanup("users", "questions", "feature_controls") }()
ctx := context.Background()
usersStore := NewUsersStore(db)
questionsStore := NewQuestionsStore(db)
opts := CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "ban",
Background: "background.png",
Intro: "Be cool, but also be warm.",
}
require.Nil(t, usersStore.Create(ctx, opts))
user, err := usersStore.GetByEmail(ctx, opts.Email)
require.Nil(t, err)
_, err = questionsStore.Create(ctx, CreateQuestionOptions{UserID: user.ID, Content: "hello"})
require.Nil(t, err)
require.Nil(t, usersStore.BanUserAndCleanup(ctx, user.ID))
var n int64
require.Nil(t, db.WithContext(ctx).Unscoped().Model(&User{}).Where("email = ?", opts.Email).Count(&n).Error)
require.EqualValues(t, 0, n, "封禁后用户行应被物理删除")
require.Nil(t, db.WithContext(ctx).Unscoped().Model(&Question{}).Where("user_id = ?", user.ID).Count(&n).Error)
require.EqualValues(t, 0, n, "封禁后该用户的提问应被物理删除")
require.Nil(t, usersStore.Create(ctx, opts), "封禁后应能用同一邮箱重新注册")
}