feat(service): add upload, download, file, and folder services
Add UploadService (dedup, chunk lifecycle, ComposeObject), DownloadService (Range support), FileService (ref counting), FolderService (path validation). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
178
internal/service/file_service.go
Normal file
178
internal/service/file_service.go
Normal file
@@ -0,0 +1,178 @@
|
||||
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
|
||||
bucket string
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewFileService creates a new FileService.
|
||||
func NewFileService(storage storage.ObjectStorage, blobStore *store.BlobStore, fileStore *store.FileStore, bucket string, db *gorm.DB, logger *zap.Logger) *FileService {
|
||||
return &FileService{
|
||||
storage: storage,
|
||||
blobStore: blobStore,
|
||||
fileStore: fileStore,
|
||||
bucket: bucket,
|
||||
db: db,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ListFiles returns a paginated list of files, optionally filtered by folder or search query.
|
||||
func (s *FileService) ListFiles(ctx context.Context, folderID *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)
|
||||
}
|
||||
|
||||
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, model.FileResponse{
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
FolderID: f.FolderID,
|
||||
Size: blob.FileSize,
|
||||
MimeType: blob.MimeType,
|
||||
SHA256: f.BlobSHA256,
|
||||
CreatedAt: f.CreatedAt,
|
||||
UpdatedAt: f.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user