package service import ( "context" "fmt" "io" "gcy_hpc_server/internal/model" "gcy_hpc_server/internal/server" "gcy_hpc_server/internal/storage" "gcy_hpc_server/internal/store" "go.uber.org/zap" "gorm.io/gorm" ) // FileService handles file listing, metadata, download, and deletion operations. type FileService struct { storage storage.ObjectStorage blobStore *store.BlobStore fileStore *store.FileStore folderStore *store.FolderStore bucket string db *gorm.DB logger *zap.Logger } // NewFileService creates a new FileService. func NewFileService(storage storage.ObjectStorage, blobStore *store.BlobStore, fileStore *store.FileStore, folderStore *store.FolderStore, bucket string, db *gorm.DB, logger *zap.Logger) *FileService { return &FileService{ storage: storage, blobStore: blobStore, fileStore: fileStore, folderStore: folderStore, bucket: bucket, db: db, logger: logger, } } // buildFileResponse creates a FileResponse from a File and FileBlob, including folder_path and user_id. func (s *FileService) buildFileResponse(ctx context.Context, f model.File, blob model.FileBlob) model.FileResponse { resp := model.FileResponse{ ID: f.ID, Name: f.Name, FolderID: f.FolderID, UserID: f.UserID, Size: blob.FileSize, MimeType: blob.MimeType, SHA256: f.BlobSHA256, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt, } // Determine folder_path if f.FolderID != nil && s.folderStore != nil { folder, err := s.folderStore.GetByID(ctx, *f.FolderID) if err == nil && folder != nil { resp.FolderPath = &folder.Path } } if resp.FolderPath == nil { rootPath := "/" resp.FolderPath = &rootPath } return resp } // ListFiles returns a paginated list of files, optionally filtered by folder, user, or search query. func (s *FileService) ListFiles(ctx context.Context, folderID *int64, userID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) { var files []model.File var total int64 var err error if search != "" { files, total, err = s.fileStore.Search(ctx, search, page, pageSize) } else { files, total, err = s.fileStore.List(ctx, folderID, page, pageSize) } if err != nil { return nil, 0, fmt.Errorf("list files: %w", err) } // Apply user_id filtering in service layer if userID != nil { filtered := make([]model.File, 0, len(files)) for _, f := range files { if f.UserID != nil && *f.UserID == *userID { filtered = append(filtered, f) } } files = filtered } responses := make([]model.FileResponse, 0, len(files)) for _, f := range files { blob, err := s.blobStore.GetBySHA256(ctx, f.BlobSHA256) if err != nil { return nil, 0, fmt.Errorf("get blob for file %d: %w", f.ID, err) } if blob == nil { return nil, 0, fmt.Errorf("blob not found for file %d", f.ID) } responses = append(responses, s.buildFileResponse(ctx, f, *blob)) } return responses, total, nil } // GetFileMetadata returns the file and its associated blob metadata. func (s *FileService) GetFileMetadata(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error) { file, err := s.fileStore.GetByID(ctx, fileID) if err != nil { return nil, nil, fmt.Errorf("get file: %w", err) } if file == nil { return nil, nil, fmt.Errorf("file not found: %d", fileID) } blob, err := s.blobStore.GetBySHA256(ctx, file.BlobSHA256) if err != nil { return nil, nil, fmt.Errorf("get blob: %w", err) } if blob == nil { return nil, nil, fmt.Errorf("blob not found for file %d", fileID) } return file, blob, nil } // GetFileResponse returns a fully populated FileResponse for a given file ID. func (s *FileService) GetFileResponse(ctx context.Context, fileID int64) (*model.FileResponse, error) { file, blob, err := s.GetFileMetadata(ctx, fileID) if err != nil { return nil, err } resp := s.buildFileResponse(ctx, *file, *blob) return &resp, nil } // DownloadFile returns a reader for the file content, along with file and blob metadata. // If rangeHeader is non-empty, it parses the range and returns partial content. func (s *FileService) DownloadFile(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error) { file, blob, err := s.GetFileMetadata(ctx, fileID) if err != nil { return nil, nil, nil, 0, 0, err } var start, end int64 if rangeHeader != "" { start, end, err = server.ParseRange(rangeHeader, blob.FileSize) if err != nil { return nil, nil, nil, 0, 0, fmt.Errorf("parse range: %w", err) } } else { start = 0 end = blob.FileSize - 1 } reader, _, err := s.storage.GetObject(ctx, s.bucket, blob.MinioKey, storage.GetOptions{ Start: &start, End: &end, }) if err != nil { return nil, nil, nil, 0, 0, fmt.Errorf("get object: %w", err) } return reader, file, blob, start, end, nil } // DeleteFile soft-deletes a file. If no other active files reference the same blob, // it decrements the blob ref count and removes the object from storage when ref count reaches 0. func (s *FileService) DeleteFile(ctx context.Context, fileID int64) error { return s.db.Transaction(func(tx *gorm.DB) error { txFileStore := store.NewFileStore(tx) txBlobStore := store.NewBlobStore(tx) blobSHA256, err := txFileStore.GetBlobSHA256ByID(ctx, fileID) if err != nil { return fmt.Errorf("get blob sha256: %w", err) } if blobSHA256 == "" { return fmt.Errorf("file not found: %d", fileID) } if err := tx.Delete(&model.File{}, fileID).Error; err != nil { return fmt.Errorf("soft delete file: %w", err) } activeCount, err := txFileStore.CountByBlobSHA256(ctx, blobSHA256) if err != nil { return fmt.Errorf("count active refs: %w", err) } if activeCount == 0 { newRefCount, err := txBlobStore.DecrementRef(ctx, blobSHA256) if err != nil { return fmt.Errorf("decrement ref: %w", err) } if newRefCount == 0 { blob, err := txBlobStore.GetBySHA256(ctx, blobSHA256) if err != nil { return fmt.Errorf("get blob for cleanup: %w", err) } if blob != nil { if err := s.storage.RemoveObject(ctx, s.bucket, blob.MinioKey, storage.RemoveObjectOptions{}); err != nil { return fmt.Errorf("remove object: %w", err) } if err := txBlobStore.Delete(ctx, blobSHA256); err != nil { return fmt.Errorf("delete blob: %w", err) } } } } return nil }) }