Files
hpc/internal/service/file_service.go
dailz f0847d3978 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>
2026-04-15 09:23:09 +08:00

179 lines
5.0 KiB
Go

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