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) }