可靠性: 完善 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
+60 -16
View File
@@ -10,6 +10,8 @@ import (
"github.com/pkg/errors"
"github.com/samber/lo"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/conf"
)
var UploadImages UploadImagesStore
@@ -23,12 +25,8 @@ type UploadImagesStore interface {
// 用于账号导出等场景,将 N 次单题查询合并为少量 IN 查询,避免 N+1。
GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*UploadImage, error)
GetByTypeQuestionID(ctx context.Context, typ UploadImageQuestionType, questionID uint) ([]*UploadImage, error)
// DeleteByQuestionID 删除某条提问时级联清理其全部上传图片记录与关联行。
//
// 用 Unscoped 真删:图片元数据(name/md5/key)随提问一并真删,与 questions.DeleteByID
// 对主表的真删口径一致。软删会留下带 deleted_at 的图片行,且关联行的 question_id
// 指向已不存在的提问。注意这里只清数据库记录,对象存储里的实际文件不在本函数删除
// (图片可能被多条记录复用,避免误删共享资源)。
// DeleteByQuestionID 先删除当前题目的关联,再物理删除不再被任何题目引用的主体。
// 实际删除的主体的对象 key 与元数据删除在同一事务中登记 R2 outbox。
DeleteByQuestionID(ctx context.Context, questionID uint) error
}
@@ -87,9 +85,24 @@ func (db *uploadImages) Create(ctx context.Context, opts CreateUploadImageOption
}
if opts.IsDeletingPrevious {
if err := tx.WithContext(ctx).Where("type = ? AND question_id = ?", opts.Type, opts.QuestionID).Delete(&UploadImageQuestion{}).Error; err != nil {
var previousLinks []*UploadImageQuestion
if err := tx.WithContext(ctx).
Where("type = ? AND question_id = ?", opts.Type, opts.QuestionID).
Find(&previousLinks).Error; err != nil {
return errors.Wrap(err, "get previous image question links")
}
previousImageIDs := lo.Map(previousLinks, func(item *UploadImageQuestion, _ int) uint {
return item.UploadImageID
})
if err := tx.WithContext(ctx).
Where("type = ? AND question_id = ?", opts.Type, opts.QuestionID).
Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete previous image question link")
}
// 新图已经创建但尚未关联;即使它与旧图使用相同 key,也绝不加入删除队列。
if err := deleteUnreferencedUploadImages(ctx, tx, previousImageIDs, opts.Key); err != nil {
return err
}
}
if err := tx.WithContext(ctx).Create(&UploadImageQuestion{
Type: opts.Type,
@@ -167,10 +180,44 @@ func (db *uploadImages) GetByTypeQuestionID(ctx context.Context, typ UploadImage
return db.getBy(ctx, "type = ? AND question_id = ?", typ, questionID)
}
// DeleteByQuestionID 删除某条提问时级联清理其全部上传图片记录(软删除,与提问保持一致)。
// 注意:question_id 列在关联表 upload_image_questions 上,不在 upload_images 表上,
// 需先查关联行拿到 imageID 列表,再软删图片主体并删除关联行(同一事务内完成)。
// 注:仅清理数据库记录,对象存储中的实际文件不在此处删除(避免误删复用资源)。
// unreferencedUploadImagePredicate 是所有 UploadImage 删除入口共用的共享保护谓词。
// 调用方必须先解除当前作用域的关联,再使用该谓词收集并删除真正的孤儿主体。
const unreferencedUploadImagePredicate = `NOT EXISTS (
SELECT 1 FROM upload_image_questions AS shared_links
WHERE shared_links.upload_image_id = upload_images.id
)`
func deleteUnreferencedUploadImages(ctx context.Context, tx *gorm.DB, imageIDs []uint, excludedKeys ...string) error {
if len(imageIDs) == 0 {
return nil
}
var images []*UploadImage
if err := tx.WithContext(ctx).Unscoped().
Where("id IN ?", imageIDs).
Where(unreferencedUploadImagePredicate).
Find(&images).Error; err != nil {
return errors.Wrap(err, "get unreferenced upload images")
}
if len(images) == 0 {
return nil
}
ids := lo.Map(images, func(image *UploadImage, _ int) uint { return image.ID })
if err := tx.WithContext(ctx).Unscoped().
Where("id IN ?", ids).
Where(unreferencedUploadImagePredicate).
Delete(&UploadImage{}).Error; err != nil {
return errors.Wrap(err, "delete unreferenced upload images")
}
values := lo.Map(images, func(image *UploadImage, _ int) string { return image.Key })
if err := enqueueR2DeleteTasksForValues(tx.WithContext(ctx), conf.Upload.ImageBucket, values, excludedKeys...); err != nil {
return errors.Wrap(err, "enqueue deleted upload images")
}
return nil
}
func (db *uploadImages) DeleteByQuestionID(ctx context.Context, questionID uint) error {
return db.Transaction(func(tx *gorm.DB) error {
var links []*UploadImageQuestion
@@ -184,13 +231,10 @@ func (db *uploadImages) DeleteByQuestionID(ctx context.Context, questionID uint)
imageIDs := lo.Map(links, func(item *UploadImageQuestion, _ int) uint {
return item.UploadImageID
})
if err := tx.WithContext(ctx).Where("id IN ?", imageIDs).Unscoped().Delete(&UploadImage{}).Error; err != nil {
return errors.Wrap(err, "delete upload images by question id")
}
// 关联表 UploadImageQuestion 无 DeletedAt,Delete 本就是真删,无需 Unscoped。
// 关联表无 DeletedAt,Delete 本就是真删;先解除当前题目关联。
if err := tx.WithContext(ctx).Where("question_id = ?", questionID).Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete image question links")
}
return nil
return deleteUnreferencedUploadImages(ctx, tx, imageIDs)
})
}