Files
hpc/internal/service/file_service.go
dailz 1d591efeba feat(model,service): add folder_path and user_id to FileResponse, add user_id filter to ListFiles
- FileResponse gains folder_path ("/" for root) and user_id fields

- folder_id no longer uses omitempty, root files return null

- ListFiles accepts optional userID parameter for filtering by owner

- New buildFileResponse helper populates folder_path from FolderStore

- New GetFileResponse method wraps GetFileMetadata + buildFileResponse

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-16 16:04:22 +08:00

222 lines
6.3 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
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
})
}