From 1d591efebac675839e7252eabef5f3970fc6667c Mon Sep 17 00:00:00 2001 From: dailz Date: Thu, 16 Apr 2026 16:04:22 +0800 Subject: [PATCH] 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 --- internal/model/file.go | 18 +++--- internal/service/file_service.go | 93 ++++++++++++++++++++------- internal/service/file_service_test.go | 14 ++-- 3 files changed, 85 insertions(+), 40 deletions(-) diff --git a/internal/model/file.go b/internal/model/file.go index 4668289..f80fc70 100644 --- a/internal/model/file.go +++ b/internal/model/file.go @@ -130,14 +130,16 @@ type UploadSessionResponse struct { // FileResponse is the DTO for a file in API responses. type FileResponse struct { - ID int64 `json:"id"` - Name string `json:"name"` - FolderID *int64 `json:"folder_id,omitempty"` - Size int64 `json:"size"` - MimeType string `json:"mime_type"` - SHA256 string `json:"sha256"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + FolderID *int64 `json:"folder_id"` + FolderPath *string `json:"folder_path"` + UserID *int64 `json:"user_id"` + Size int64 `json:"size"` + MimeType string `json:"mime_type"` + SHA256 string `json:"sha256"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // FolderResponse is the DTO for a folder in API responses. diff --git a/internal/service/file_service.go b/internal/service/file_service.go index 586ae92..f67595f 100644 --- a/internal/service/file_service.go +++ b/internal/service/file_service.go @@ -16,28 +16,59 @@ import ( // 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 + 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, bucket string, db *gorm.DB, logger *zap.Logger) *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, - bucket: bucket, - db: db, - logger: logger, + storage: storage, + blobStore: blobStore, + fileStore: fileStore, + folderStore: folderStore, + 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) { +// 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 @@ -51,6 +82,17 @@ func (s *FileService) ListFiles(ctx context.Context, folderID *int64, page, page 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) @@ -61,16 +103,7 @@ func (s *FileService) ListFiles(ctx context.Context, folderID *int64, page, page 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, - }) + responses = append(responses, s.buildFileResponse(ctx, f, *blob)) } return responses, total, nil @@ -97,6 +130,16 @@ func (s *FileService) GetFileMetadata(ctx context.Context, fileID int64) (*model 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) { diff --git a/internal/service/file_service_test.go b/internal/service/file_service_test.go index 1f7875d..0753491 100644 --- a/internal/service/file_service_test.go +++ b/internal/service/file_service_test.go @@ -78,7 +78,7 @@ func setupFileTestDB(t *testing.T) *gorm.DB { if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := db.AutoMigrate(&model.File{}, &model.FileBlob{}); err != nil { + if err := db.AutoMigrate(&model.File{}, &model.FileBlob{}, &model.Folder{}); err != nil { t.Fatalf("migrate: %v", err) } return db @@ -88,7 +88,7 @@ func setupFileService(t *testing.T) (*FileService, *mockFileStorage, *gorm.DB) { t.Helper() db := setupFileTestDB(t) ms := &mockFileStorage{} - svc := NewFileService(ms, store.NewBlobStore(db), store.NewFileStore(db), "test-bucket", db, zap.NewNop()) + svc := NewFileService(ms, store.NewBlobStore(db), store.NewFileStore(db), store.NewFolderStore(db), "test-bucket", db, zap.NewNop()) return svc, ms, db } @@ -123,7 +123,7 @@ func createTestFile(t *testing.T, db *gorm.DB, name, blobSHA256 string, folderID func TestListFiles_Empty(t *testing.T) { svc, _, _ := setupFileService(t) - files, total, err := svc.ListFiles(context.Background(), nil, 1, 10, "") + files, total, err := svc.ListFiles(context.Background(), nil, nil, 1, 10, "") if err != nil { t.Fatalf("ListFiles: %v", err) } @@ -142,7 +142,7 @@ func TestListFiles_WithFiles(t *testing.T) { createTestFile(t, db, "file1.txt", blob.SHA256, nil) createTestFile(t, db, "file2.txt", blob.SHA256, nil) - files, total, err := svc.ListFiles(context.Background(), nil, 1, 10, "") + files, total, err := svc.ListFiles(context.Background(), nil, nil, 1, 10, "") if err != nil { t.Fatalf("ListFiles: %v", err) } @@ -173,7 +173,7 @@ func TestListFiles_Search(t *testing.T) { createTestFile(t, db, "document.pdf", "sha256other", nil) createTestBlob(t, db, "sha256other", "blobs/other", "application/pdf", 512, 1) - files, total, err := svc.ListFiles(context.Background(), nil, 1, 10, "photo") + files, total, err := svc.ListFiles(context.Background(), nil, nil, 1, 10, "photo") if err != nil { t.Fatalf("ListFiles: %v", err) } @@ -446,7 +446,7 @@ func TestListFiles_WithFolderFilter(t *testing.T) { createTestFile(t, db, "in_folder.txt", blob.SHA256, &folderID) createTestFile(t, db, "root.txt", blob.SHA256, nil) - files, total, err := svc.ListFiles(context.Background(), &folderID, 1, 10, "") + files, total, err := svc.ListFiles(context.Background(), &folderID, nil, 1, 10, "") if err != nil { t.Fatalf("ListFiles: %v", err) } @@ -460,7 +460,7 @@ func TestListFiles_WithFolderFilter(t *testing.T) { t.Errorf("expected in_folder.txt, got %s", files[0].Name) } - rootFiles, rootTotal, err := svc.ListFiles(context.Background(), nil, 1, 10, "") + rootFiles, rootTotal, err := svc.ListFiles(context.Background(), nil, nil, 1, 10, "") if err != nil { t.Fatalf("ListFiles root: %v", err) }