可靠性: 完善 R2 备份恢复与对象生命周期

This commit is contained in:
tamakyi
2026-11-05 08:41:55 +08:00
parent 3e7719f200
commit 937e688cfa
32 changed files with 4151 additions and 231 deletions
+66 -23
View File
@@ -386,10 +386,25 @@ func (db *users) Update(ctx context.Context, id uint, opts UpdateUserOptions) er
"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
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current User
if err := tx.WithContext(ctx).Select("id", "avatar", "background", "background_image").First(&current, id).Error; err != nil {
return errors.Wrap(err, "get current profile images")
}
if err := tx.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return errors.Wrap(err, "update user")
}
oldValues := make([]string, 0, 3)
for _, pair := range [][2]string{{current.Avatar, opts.Avatar}, {current.Background, opts.Background}, {current.BackgroundImage, opts.BackgroundImage}} {
if pair[0] != pair[1] {
oldValues = append(oldValues, pair[0])
}
}
if err := enqueueR2DeleteTasksForValues(tx.WithContext(ctx), conf.Upload.ImageBucket, oldValues, opts.Avatar, opts.Background, opts.BackgroundImage); err != nil {
return errors.Wrap(err, "enqueue replaced profile images")
}
return nil
})
}
// UpdateHarassmentType 仅更新骚扰防护类型(none / register_only)。
@@ -1021,6 +1036,19 @@ func (db *users) BanUserAndCleanup(ctx context.Context, userID uint) error {
// 直接复用会把上一条语句的 Where 条件串到下一条上(曾把 messages 的条件串进 follow_ups,
// 报「字段 conversation_id 不存在」)。
func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
var profile User
if err := tx.WithContext(ctx).Select("id", "avatar", "background", "background_image").First(&profile, userID).Error; err != nil {
return errors.Wrap(err, "get profile images of user")
}
if err := enqueueR2DeleteTasksForValues(tx.WithContext(ctx), conf.Upload.ImageBucket, []string{profile.Avatar, profile.Background, profile.BackgroundImage}); err != nil {
return errors.Wrap(err, "enqueue profile images of user")
}
if err := tx.WithContext(ctx).Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"avatar": "", "background": "", "background_image": "",
}).Error; err != nil {
return errors.Wrap(err, "clear profile images of user")
}
// 追问:属于该用户提问下的追问
if err := tx.WithContext(ctx).Unscoped().
Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID).
@@ -1029,11 +1057,31 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
}
// 消息图片必须先于消息删除,否则消息子查询将无法再匹配待删图片。
// 新数据优先使用物化 ObjectKey,旧数据回退到 URL;外部 URL 不会进入 R2 outbox。
messageImageScope := "message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))"
var messageImages []*MessageImage
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).
Where(messageImageScope, userID, userID).
Find(&messageImages).Error; err != nil {
return errors.Wrap(err, "get message images of user")
}
messageImageValues := make([]string, 0, len(messageImages))
for _, image := range messageImages {
value := image.ObjectKey
if value == "" {
value = image.URL
}
messageImageValues = append(messageImageValues, value)
}
if err := tx.WithContext(ctx).Unscoped().
Where(messageImageScope, userID, userID).
Delete(&MessageImage{}).Error; err != nil {
return errors.Wrap(err, "delete message images of user")
}
if err := enqueueR2DeleteTasksForValues(tx.WithContext(ctx), conf.Upload.ImageBucket, messageImageValues); err != nil {
return errors.Wrap(err, "enqueue deleted message images")
}
// 消息:属于该用户作为 owner 或 asker 的会话
if err := tx.WithContext(ctx).Unscoped().
Where("conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?)", userID, userID).
@@ -1048,30 +1096,25 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
return errors.Wrap(err, "delete conversations of user")
}
// 提问图片元数据:删除本用户上传的孤立图片,以及仅链接到本用户提问的图片。
// 仍被其他提问引用的图片必须保留;这里只清数据库元数据,不操作对象存储。
if err := tx.WithContext(ctx).Unscoped().
Where(`(
uploader_user_id = ?
OR upload_images.id IN (
SELECT upload_image_id FROM upload_image_questions
WHERE question_id IN (SELECT id FROM questions WHERE user_id = ?)
)
) AND NOT EXISTS (
SELECT 1 FROM upload_image_questions AS shared_links
WHERE shared_links.upload_image_id = upload_images.id
AND shared_links.question_id NOT IN (SELECT id FROM questions WHERE user_id = ?)
)`, userID, userID, userID).
Delete(&UploadImage{}).Error; err != nil {
return errors.Wrap(err, "delete upload images of user")
// 先确定本用户上传或仅与当前作用域有关的候选主体,再解除本用户题目的关联。
// 最终是否可删除由与 UploadImages.DeleteByQuestionID 完全相同的共享保护谓词决定。
var uploadImageIDs []uint
if err := tx.WithContext(ctx).Unscoped().Model(&UploadImage{}).
Where(`uploader_user_id = ? OR upload_images.id IN (
SELECT upload_image_id FROM upload_image_questions
WHERE question_id IN (SELECT id FROM questions WHERE user_id = ?)
)`, userID, userID).
Pluck("upload_images.id", &uploadImageIDs).Error; err != nil {
return errors.Wrap(err, "get upload images of user")
}
// 提问-图片关联
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 := deleteUnreferencedUploadImages(ctx, tx, uploadImageIDs); err != nil {
return errors.Wrap(err, "delete upload images of user")
}
// 提问
if err := tx.WithContext(ctx).Unscoped().