241 lines
8.3 KiB
Go
241 lines
8.3 KiB
Go
// 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)
|
|
})
|
|
}
|