231 lines
7.9 KiB
Go
231 lines
7.9 KiB
Go
package biz
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/google/uuid"
|
|
"io"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type MediaFile struct {
|
|
ID uint
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
Name string
|
|
CategoryID int
|
|
URL, Tag, Key string
|
|
Size int64
|
|
Mime, MD5 string
|
|
UserID uint
|
|
}
|
|
type AttachmentCategory struct {
|
|
ID uint
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
Name string
|
|
ParentID uint
|
|
Children []*AttachmentCategory
|
|
}
|
|
type UploadSession struct {
|
|
ID uint
|
|
UserID uint
|
|
FileName, FileHash string
|
|
FileSize, ChunkSize int64
|
|
ChunkTotal int
|
|
Status, StorageKey string
|
|
MediaID uint
|
|
}
|
|
type UploadChunk struct {
|
|
Index int
|
|
Hash string
|
|
Size int64
|
|
}
|
|
type MediaRepo interface {
|
|
CreateMedia(context.Context, *MediaFile) error
|
|
FindMedia(context.Context, uint) (*MediaFile, error)
|
|
FindMediaByHash(context.Context, uint, string) (*MediaFile, error)
|
|
ListMedia(context.Context, int, int, string, int, string, uint) ([]*MediaFile, int64, error)
|
|
UpdateMediaName(context.Context, uint, string) error
|
|
DeleteMedia(context.Context, uint) error
|
|
MediaKeyReferences(context.Context, string) (int64, error)
|
|
CreateMediaBatch(context.Context, []*MediaFile) error
|
|
SaveCategory(context.Context, *AttachmentCategory) error
|
|
DeleteCategory(context.Context, uint) error
|
|
ListCategories(context.Context) ([]*AttachmentCategory, error)
|
|
FindUploadingSession(context.Context, uint, string) (*UploadSession, error)
|
|
CreateUploadSession(context.Context, *UploadSession) error
|
|
FindUploadSession(context.Context, uint) (*UploadSession, error)
|
|
CompleteUploadSession(context.Context, uint, string, uint) error
|
|
DeleteUploadSession(context.Context, uint) error
|
|
UpsertChunk(context.Context, uint, *UploadChunk) error
|
|
ListChunks(context.Context, uint) ([]*UploadChunk, error)
|
|
DeleteChunks(context.Context, uint) error
|
|
}
|
|
type MediaUsecase struct {
|
|
repo MediaRepo
|
|
files FileStorage
|
|
}
|
|
|
|
func NewMediaUsecase(repo MediaRepo, files FileStorage) *MediaUsecase {
|
|
return &MediaUsecase{repo: repo, files: files}
|
|
}
|
|
func (uc *MediaUsecase) Repo() MediaRepo { return uc.repo }
|
|
|
|
var blockedExtensions = map[string]bool{".exe": true, ".dll": true, ".bat": true, ".cmd": true, ".sh": true, ".php": true, ".jsp": true, ".asp": true, ".aspx": true, ".html": true, ".htm": true, ".svg": true}
|
|
|
|
func validateMediaName(name string) error {
|
|
if name == "" || filepath.Base(name) != name {
|
|
return errors.New("文件名不合法")
|
|
}
|
|
if blockedExtensions[strings.ToLower(filepath.Ext(name))] {
|
|
return errors.New("文件类型不允许上传")
|
|
}
|
|
return nil
|
|
}
|
|
func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, mime string, categoryID int, reader io.Reader, save bool) (*MediaFile, error) {
|
|
if err := validateMediaName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(name))
|
|
key := time.Now().Format("20060102") + "/" + uuid.NewString() + ext
|
|
hash := md5.New()
|
|
stored, err := uc.files.Put(ctx, key, io.TeeReader(reader, hash))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
media := &MediaFile{Name: name, CategoryID: categoryID, URL: stored.URL, Tag: strings.TrimPrefix(ext, "."), Key: key, Size: stored.Size, Mime: mime, MD5: hex.EncodeToString(hash.Sum(nil)), UserID: userID}
|
|
if save {
|
|
if err = uc.repo.CreateMedia(ctx, media); err != nil {
|
|
_ = uc.files.Delete(ctx, key)
|
|
return nil, err
|
|
}
|
|
}
|
|
return media, nil
|
|
}
|
|
func (uc *MediaUsecase) Delete(ctx context.Context, id uint) error {
|
|
media, err := uc.repo.FindMedia(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
count, err := uc.repo.MediaKeyReferences(ctx, media.Key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count <= 1 {
|
|
if err = uc.files.Delete(ctx, media.Key); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return uc.repo.DeleteMedia(ctx, id)
|
|
}
|
|
func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash string, size, chunkSize int64, total int) (*UploadSession, *MediaFile, []int, error) {
|
|
if err := validateMediaName(name); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
if size <= 0 || chunkSize <= 0 || total <= 0 {
|
|
return nil, nil, nil, errors.New("上传参数不合法")
|
|
}
|
|
if media, err := uc.repo.FindMediaByHash(ctx, userID, hash); err == nil {
|
|
copy := *media
|
|
copy.ID = 0
|
|
copy.Name = name
|
|
if err = uc.repo.CreateMedia(ctx, ©); err == nil {
|
|
return nil, ©, nil, nil
|
|
}
|
|
}
|
|
session, err := uc.repo.FindUploadingSession(ctx, userID, hash)
|
|
if err != nil {
|
|
session = &UploadSession{UserID: userID, FileName: name, FileHash: hash, FileSize: size, ChunkSize: chunkSize, ChunkTotal: total, Status: "uploading"}
|
|
if err = uc.repo.CreateUploadSession(ctx, session); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
}
|
|
chunks, err := uc.repo.ListChunks(ctx, session.ID)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
indices := make([]int, 0, len(chunks))
|
|
for _, v := range chunks {
|
|
indices = append(indices, v.Index)
|
|
}
|
|
sort.Ints(indices)
|
|
return session, nil, indices, nil
|
|
}
|
|
func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, index int, expected string, reader io.Reader) error {
|
|
session, err := uc.repo.FindUploadSession(ctx, uploadID)
|
|
if err != nil || session.UserID != userID {
|
|
return errors.New("上传会话不存在或无权操作")
|
|
}
|
|
if session.Status != "uploading" || index < 0 || index >= session.ChunkTotal {
|
|
return errors.New("上传会话状态或分片序号不合法")
|
|
}
|
|
hash := md5.New()
|
|
key := fmt.Sprintf(".chunks/%d/%08d", uploadID, index)
|
|
stored, err := uc.files.Put(ctx, key, io.TeeReader(reader, hash))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
actual := hex.EncodeToString(hash.Sum(nil))
|
|
if !strings.EqualFold(actual, expected) {
|
|
_ = uc.files.Delete(ctx, key)
|
|
return errors.New("分片校验失败")
|
|
}
|
|
return uc.repo.UpsertChunk(ctx, uploadID, &UploadChunk{Index: index, Hash: actual, Size: stored.Size})
|
|
}
|
|
func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uint, mime string) (*MediaFile, error) {
|
|
session, err := uc.repo.FindUploadSession(ctx, uploadID)
|
|
if err != nil || session.UserID != userID {
|
|
return nil, errors.New("上传会话不存在或无权操作")
|
|
}
|
|
chunks, err := uc.repo.ListChunks(ctx, uploadID)
|
|
if err != nil || len(chunks) != session.ChunkTotal {
|
|
return nil, errors.New("分片不完整")
|
|
}
|
|
sort.Slice(chunks, func(i, j int) bool { return chunks[i].Index < chunks[j].Index })
|
|
names := make([]string, 0, len(chunks))
|
|
for index, chunk := range chunks {
|
|
if chunk.Index != index {
|
|
return nil, errors.New("分片序号不连续")
|
|
}
|
|
names = append(names, fmt.Sprintf(".chunks/%d/%08d", uploadID, index))
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(session.FileName))
|
|
key := time.Now().Format("20060102") + "/" + uuid.NewString() + ext
|
|
stored, hash, err := uc.files.Compose(ctx, names, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !strings.EqualFold(hash, session.FileHash) {
|
|
_ = uc.files.Delete(ctx, key)
|
|
return nil, errors.New("整文件校验失败")
|
|
}
|
|
media := &MediaFile{Name: session.FileName, URL: stored.URL, Tag: strings.TrimPrefix(ext, "."), Key: key, Size: stored.Size, Mime: mime, MD5: hash, UserID: userID}
|
|
if err = uc.repo.CreateMedia(ctx, media); err != nil {
|
|
_ = uc.files.Delete(ctx, key)
|
|
return nil, err
|
|
}
|
|
_ = uc.repo.CompleteUploadSession(ctx, uploadID, key, media.ID)
|
|
_ = uc.repo.DeleteChunks(ctx, uploadID)
|
|
_ = uc.files.DeletePrefix(ctx, fmt.Sprintf(".chunks/%d", uploadID))
|
|
return media, nil
|
|
}
|
|
func (uc *MediaUsecase) CancelUpload(ctx context.Context, userID, uploadID uint) error {
|
|
session, err := uc.repo.FindUploadSession(ctx, uploadID)
|
|
if err != nil || session.UserID != userID {
|
|
return errors.New("上传会话不存在或无权操作")
|
|
}
|
|
_ = uc.repo.DeleteChunks(ctx, uploadID)
|
|
_ = uc.files.DeletePrefix(ctx, fmt.Sprintf(".chunks/%d", uploadID))
|
|
return uc.repo.DeleteUploadSession(ctx, uploadID)
|
|
}
|
|
func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string, limit int) ([]*StoredFile, string, bool, error) {
|
|
return uc.files.List(ctx, prefix, cursor, limit)
|
|
}
|