package system import ( "bufio" "context" "crypto/md5" "encoding/hex" "errors" "io" "net/http" "path/filepath" "strings" "sync" "time" "github.com/google/uuid" "kra/internal/utils/uploadpolicy" ) type MediaRepo interface { MediaMetadataRepo UploadRepo } type StoredFile struct { Name string Path string URL string Size int64 LastModified time.Time ContentType string } // FileStorage owns the persistence boundary for uploaded files. type FileStorage interface { Put(context.Context, string, io.Reader) (*StoredFile, error) Open(context.Context, string) (io.ReadCloser, error) Delete(context.Context, string) error Compose(context.Context, []string, string) (*StoredFile, string, error) DeletePrefix(context.Context, string) error List(context.Context, string, string, int) ([]*StoredFile, string, bool, error) } type MediaUsecase struct { MediaRepo files FileStorage settings RuntimeSettings } var mediaDeleteMu sync.Mutex var ErrMediaTooLarge = errors.New("文件超过大小上限") func NewMediaUsecase(repo MediaRepo, files FileStorage, settings RuntimeSettings) *MediaUsecase { return &MediaUsecase{MediaRepo: repo, files: files, settings: settings} } func (uc *MediaUsecase) maxMediaFileSize() int64 { if uc.settings == nil { return MediaSettings{}.EffectiveMaxFileSize() } return uc.settings.MediaSettings().EffectiveMaxFileSize() } func validateMediaName(name string) error { return uploadpolicy.ValidateFileName(name) } func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, suppliedMIME 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)) buffered := bufio.NewReader(reader) // Keep the multipart Content-Type when the client supplied one. // Only infer it when the header is empty: first by extension, then by // sniffing the first 512 bytes. Overwriting a valid client MIME with // http.DetectContentType changes the media record for common uploads // (for example SVG/JSON), and diverges from the compatible DetectMIME helper. if strings.TrimSpace(suppliedMIME) == "" { if detected := uploadpolicy.MIMEByExtension(ext); detected != "" { suppliedMIME = detected } else if header, _ := buffered.Peek(512); len(header) > 0 { suppliedMIME = http.DetectContentType(header) } else { suppliedMIME = "application/octet-stream" } } key := time.Now().Format("20060102") + "/" + uuid.NewString() + ext hash := md5.New() maxSize := uc.maxMediaFileSize() limited := &io.LimitedReader{R: buffered, N: maxSize + 1} stored, err := uc.files.Put(ctx, key, io.TeeReader(limited, hash)) if err != nil { return nil, err } readSize := maxSize + 1 - limited.N if stored == nil { _ = uc.files.Delete(ctx, key) return nil, errors.New("文件存储未返回结果") } if readSize > maxSize || stored.Size > maxSize { _ = uc.files.Delete(ctx, key) return nil, ErrMediaTooLarge } if stored.Size != readSize { _ = uc.files.Delete(ctx, key) return nil, errors.New("文件存储大小不一致") } media := &MediaFile{Name: name, CategoryID: categoryID, URL: stored.URL, Tag: strings.TrimPrefix(filepath.Ext(name), "."), Key: key, Size: stored.Size, Mime: suppliedMIME, MD5: hex.EncodeToString(hash.Sum(nil)), UserID: userID} if save { count, countErr := uc.MediaKeyReferences(ctx, key) if countErr != nil { _ = uc.files.Delete(ctx, key) return nil, countErr } if count == 0 { if err = uc.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 { mediaDeleteMu.Lock() defer mediaDeleteMu.Unlock() media, err := uc.FindMedia(ctx, id) if err != nil { return err } count, err := uc.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.DeleteMedia(ctx, id) } func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string, limit int) ([]*StoredFile, string, bool, error) { if limit <= 0 { limit = 100 } // Multipart chunks are an internal staging detail. The compatible local // implementation keeps them outside the configured object-storage root, so // the public listing must not expose them even when the selected backend is // also used for staging. chunkRoot := "uploads/chunks" if uc.settings != nil { if configured := strings.Trim(uc.settings.MediaSettings().ChunkDir, "/\\ "); configured != "" { chunkRoot = configured } } chunkRoot = strings.Trim(chunkRoot, "/") + "/" result := make([]*StoredFile, 0, limit) nextCursor := cursor for { // Ask only for the remaining visible capacity. Otherwise a page that is // partially filtered could fill the response halfway through the next // storage page while returning that page's end cursor, skipping objects. remaining := limit - len(result) items, next, more, err := uc.files.List(ctx, prefix, nextCursor, remaining) if err != nil { return nil, "", false, err } for _, item := range items { if item == nil || strings.HasPrefix(strings.TrimPrefix(item.Path, "/"), chunkRoot) { continue } result = append(result, item) if len(result) == limit { return result, next, more, nil } } if !more || next == "" || next == nextCursor { return result, next, more, nil } nextCursor = next } }