fix login error
This commit is contained in:
+148
-68
@@ -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
|
||||
}
|
||||
|
||||
@@ -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), "封禁后应能用同一邮箱重新注册")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user