feat(svc): implement soft-delete in FileService

Delete now performs soft-delete (status='user_deleted') instead of physical deletion.

Removes storage content deletion — files are preserved on disk after soft-delete.

Second delete on already-deleted file returns nil (idempotent).

Add tests: SoftDelete, SoftDeleteKeepsStorage, SoftDeleteIdempotent, SoftDeleteForbidden.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-07-04 17:20:34 +08:00
parent ac98b5ddbd
commit e04e39bea8
2 changed files with 91 additions and 11 deletions
+7 -9
View File
@@ -129,6 +129,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
MimeType: mimeType, MimeType: mimeType,
StoragePath: storagePath, StoragePath: storagePath,
Hash: hex.EncodeToString(hasher.Sum(nil)), Hash: hex.EncodeToString(hasher.Sum(nil)),
Status: model.StatusActive,
IsDir: false, IsDir: false,
} }
@@ -240,10 +241,14 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
return modelToFileInfo(file), nil return modelToFileInfo(file), nil
} }
// Delete removes a file or (empty) directory. // Delete soft-deletes a file or (empty) directory. Storage content is preserved.
func (s *FileService) Delete(ctx context.Context, userID, fileID string) error { func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
file, err := s.getOwnedFile(ctx, userID, fileID) file, err := s.getOwnedFile(ctx, userID, fileID)
if err != nil { if err != nil {
var ae *model.AppError
if errors.As(err, &ae) && errors.Is(ae.Err, model.ErrNotFound) {
return nil // idempotent: already deleted
}
return err return err
} }
@@ -262,14 +267,6 @@ func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
return model.NewInternalError("delete file record", err) return model.NewInternalError("delete file record", err)
} }
// Remove content from storage (directories have no stored content).
if !file.IsDir {
if err := s.storage.Delete(ctx, file.StoragePath); err != nil {
s.logger.WarnContext(ctx, "failed to delete file from storage",
"path", file.StoragePath, "error", err)
}
}
return nil return nil
} }
@@ -297,6 +294,7 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st
UserID: userID, UserID: userID,
ParentID: parentID, ParentID: parentID,
Name: dirName, Name: dirName,
Status: model.StatusActive,
IsDir: true, IsDir: true,
} }
+84 -2
View File
@@ -73,7 +73,7 @@ func (s *memStorage) Delete(_ context.Context, path string) error {
var _ storage.StorageBackend = (*memStorage)(nil) var _ storage.StorageBackend = (*memStorage)(nil)
func setupFileService(t *testing.T) *FileService { func setupFileServiceWithStorage(t *testing.T) (*FileService, *memStorage) {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -87,7 +87,12 @@ func setupFileService(t *testing.T) *FileService {
fileRepo := repository.NewFileRepository(db) fileRepo := repository.NewFileRepository(db)
store := newMemStorage() store := newMemStorage()
return NewFileService(fileRepo, store, 0, nil) // 0 = unlimited, nil logger defaults to slog.Default() return NewFileService(fileRepo, store, 0, nil), store // 0 = unlimited, nil logger defaults to slog.Default()
}
func setupFileService(t *testing.T) *FileService {
svc, _ := setupFileServiceWithStorage(t)
return svc
} }
func TestFileService_Upload(t *testing.T) { func TestFileService_Upload(t *testing.T) {
@@ -461,3 +466,80 @@ func TestFileService_VerifyParentForbidden(t *testing.T) {
_, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data")) _, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data"))
assertAppError(t, err, http.StatusForbidden) assertAppError(t, err, http.StatusForbidden)
} }
func TestFileService_SoftDelete(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
info, err := svc.Upload(ctx, "user1", nil, "soft-delete.txt", strings.NewReader("data"))
if err != nil {
t.Fatalf("Upload = %v", err)
}
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, err = svc.Get(ctx, "user1", info.ID)
assertAppError(t, err, http.StatusNotFound)
}
func TestFileService_SoftDeleteKeepsStorage(t *testing.T) {
svc, store := setupFileServiceWithStorage(t)
ctx := context.Background()
content := "keep me after soft-delete"
info, err := svc.Upload(ctx, "user1", nil, "keep.txt", strings.NewReader(content))
if err != nil {
t.Fatalf("Upload = %v", err)
}
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
storagePath := "user1/" + info.ID
reader, err := store.Open(ctx, storagePath)
if err != nil {
t.Fatalf("storage should retain content after soft-delete, got: %v", err)
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read storage: %v", err)
}
if string(data) != content {
t.Errorf("storage content = %q, want %q", string(data), content)
}
}
func TestFileService_SoftDeleteIdempotent(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
info, err := svc.Upload(ctx, "user1", nil, "idem.txt", strings.NewReader("data"))
if err != nil {
t.Fatalf("Upload = %v", err)
}
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
t.Fatalf("first Delete = %v", err)
}
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
t.Fatalf("second Delete should be idempotent, got: %v", err)
}
}
func TestFileService_SoftDeleteForbidden(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
info, err := svc.Upload(ctx, "user1", nil, "forbidden.txt", strings.NewReader("data"))
if err != nil {
t.Fatalf("Upload = %v", err)
}
err = svc.Delete(ctx, "user2", info.ID)
assertAppError(t, err, http.StatusForbidden)
}