// Copyright 2023 E99p1ant. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package db import ( "context" "github.com/pkg/errors" "github.com/samber/lo" "gorm.io/gorm" "github.com/tamakyi/TamaBox/internal/conf" ) var UploadImages UploadImagesStore var _ UploadImagesStore = (*uploadImages)(nil) type UploadImagesStore interface { Create(ctx context.Context, opts CreateUploadImageOptions) (*UploadImage, error) GetByQuestionID(ctx context.Context, questionID uint) ([]*UploadImage, error) // GetByQuestionIDs 批量获取多个提问的图片,按 question_id 分组返回。 // 用于账号导出等场景,将 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 先删除当前题目的关联,再物理删除不再被任何题目引用的主体。 // 实际删除的主体的对象 key 与元数据删除在同一事务中登记 R2 outbox。 DeleteByQuestionID(ctx context.Context, questionID uint) error } func NewUploadImagesStore(db *gorm.DB) UploadImagesStore { return &uploadImages{db} } type uploadImages struct { *gorm.DB } type UploadImage struct { gorm.Model UploaderUserID uint Name string FileSize int64 Md5 string Key string } type UploadImageQuestionType string const ( UploadImageQuestionTypeAsk UploadImageQuestionType = "ask" UploadImageQuestionTypeAnswer UploadImageQuestionType = "answer" ) type UploadImageQuestion struct { Type UploadImageQuestionType UploadImageID uint `gorm:"primaryKey"` QuestionID uint `gorm:"primaryKey"` } type CreateUploadImageOptions struct { Type UploadImageQuestionType QuestionID uint UploaderUserID uint Name string FileSize int64 Md5 string Key string IsDeletingPrevious bool } func (db *uploadImages) Create(ctx context.Context, opts CreateUploadImageOptions) (*UploadImage, error) { image := &UploadImage{ UploaderUserID: opts.UploaderUserID, Name: opts.Name, FileSize: opts.FileSize, Md5: opts.Md5, Key: opts.Key, } if err := db.Transaction(func(tx *gorm.DB) error { if err := tx.WithContext(ctx).Create(image).Error; err != nil { return errors.Wrap(err, "create image") } if opts.IsDeletingPrevious { 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, UploadImageID: image.ID, QuestionID: opts.QuestionID, }).Error; err != nil { return errors.Wrap(err, "create image question link") } return nil }); err != nil { return nil, err } return image, nil } func (db *uploadImages) getBy(ctx context.Context, where string, args ...interface{}) ([]*UploadImage, error) { var uploadImageQuestions []*UploadImageQuestion if err := db.WithContext(ctx).Model(&UploadImageQuestion{}).Where(where, args...).Find(&uploadImageQuestions).Error; err != nil { return nil, errors.Wrap(err, "get image questions") } uploadImageIDs := lo.Map(uploadImageQuestions, func(item *UploadImageQuestion, _ int) uint { return item.UploadImageID }) var uploadImages []*UploadImage if err := db.WithContext(ctx).Model(&UploadImage{}).Where("id IN ?", uploadImageIDs).Order("id ASC").Find(&uploadImages).Error; err != nil { return nil, errors.Wrap(err, "get images") } return uploadImages, nil } func (db *uploadImages) GetByQuestionID(ctx context.Context, questionID uint) ([]*UploadImage, error) { return db.getBy(ctx, "question_id = ?", questionID) } // GetByQuestionIDs 批量获取多个提问的图片,按 question_id 分组返回(解决导出 N+1)。 // 实现:先按 IN 批量取关联行,再按 IN 批量取图片主体,最后在内存中按 question_id 归组, // 将每个提问原本的 2 次查询降为整体 2 次查询。 func (db *uploadImages) GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*UploadImage, error) { result := make(map[uint][]*UploadImage) if len(questionIDs) == 0 { return result, nil } var links []*UploadImageQuestion if err := db.WithContext(ctx).Model(&UploadImageQuestion{}).Where("question_id IN ?", questionIDs).Find(&links).Error; err != nil { return nil, errors.Wrap(err, "get image questions batch") } if len(links) == 0 { return result, nil } imageIDs := lo.Map(links, func(item *UploadImageQuestion, _ int) uint { return item.UploadImageID }) var images []*UploadImage if err := db.WithContext(ctx).Model(&UploadImage{}).Where("id IN ?", imageIDs).Order("id ASC").Find(&images).Error; err != nil { return nil, errors.Wrap(err, "get images batch") } imageByID := lo.SliceToMap(images, func(img *UploadImage) (uint, *UploadImage) { return img.ID, img }) for _, link := range links { if img, ok := imageByID[link.UploadImageID]; ok { result[link.QuestionID] = append(result[link.QuestionID], img) } } return result, nil } func (db *uploadImages) GetByTypeQuestionID(ctx context.Context, typ UploadImageQuestionType, questionID uint) ([]*UploadImage, error) { return db.getBy(ctx, "type = ? AND question_id = ?", typ, questionID) } // 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 if err := tx.WithContext(ctx).Where("question_id = ?", questionID).Find(&links).Error; err != nil { return errors.Wrap(err, "get image questions for delete") } if len(links) == 0 { return nil } imageIDs := lo.Map(links, func(item *UploadImageQuestion, _ int) uint { return item.UploadImageID }) // 关联表无 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 deleteUnreferencedUploadImages(ctx, tx, imageIDs) }) }