diff --git a/backend/internal/app/canvas_bridge.go b/backend/internal/app/canvas_bridge.go index f993af199..d81d97fed 100644 --- a/backend/internal/app/canvas_bridge.go +++ b/backend/internal/app/canvas_bridge.go @@ -98,11 +98,15 @@ func (h canvasHost) StructuredReplacementQuota(userID, kind string, count int, b return validateStructuredReplacementQuotaWithPolicy(usage, kind, count, bytes, policy.Resource) } -func (h canvasHost) DeleteUserAssetWithResources(userID, assetID string) error { +func (h canvasHost) DeleteUserAssetWithResources(userID, assetID string, purge bool) error { if h.svc == nil { return nil } - return h.svc.deleteUserAssetWithResources(userID, assetID) + return h.svc.deleteUserAssetWithResources(userID, assetID, purge) +} + +func (h canvasHost) PurgeUserAssetsWithResources(userID string, assetIDs []string) error { + return h.svc.deleteUserAssetsWithResources(userID, assetIDs, true) } func (h canvasHost) RecordActivity(userID, event string, count int) { @@ -182,6 +186,14 @@ func (s *Service) DeleteUserAsset(userID string, id string) error { return s.canvasDomain().DeleteUserAsset(userID, id) } +func (s *Service) PurgeUserAsset(userID string, id string) error { + return s.canvasDomain().PurgeUserAsset(userID, id) +} + +func (s *Service) PurgeUserAssets(userID string, ids []string) error { + return s.canvasDomain().PurgeUserAssets(userID, ids) +} + func (s *Service) UserAssets(userID string) ([]json.RawMessage, error) { return s.canvasDomain().UserAssets(userID) } diff --git a/backend/internal/app/canvas_history_resource_test.go b/backend/internal/app/canvas_history_resource_test.go index e050c508f..3fceb8555 100644 --- a/backend/internal/app/canvas_history_resource_test.go +++ b/backend/internal/app/canvas_history_resource_test.go @@ -66,3 +66,47 @@ func TestCanvasHistoryProtectsMediaAndDeletionWorker(t *testing.T) { t.Fatalf("unreferenced media not cleaned: %v", err) } } + +func TestArchivedAssetDeletionRemovesCanvasHistoryReference(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + resource := model.Resource{ + ID: "archived-history-resource", UserID: "user-1", Provider: "unsupported-test-provider", + ObjectKey: "users/user-1/image/archived-history.png", Status: model.ResourceStatusReady, + } + asset := model.Asset{ + ID: "archived-history-asset", UserID: "user-1", Title: "回收站历史素材", + Status: model.AssetVersionStatusArchived, PayloadJSON: `{"data":{"storageKey":"resource:archived-history-resource"}}`, + } + snapshot := model.CanvasSnapshot{ + ID: "archived-history-snapshot", CanvasID: "canvas", UserID: "user-1", Revision: 1, + Title: "历史画布", PayloadJSON: `{}`, CreatedAt: time.Now(), + } + ref := model.CanvasSnapshotResource{SnapshotID: snapshot.ID, ResourceID: resource.ID} + for _, item := range []any{&resource, &asset, &snapshot, &ref} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + if err := svc.DeleteUserAsset("user-1", asset.ID); err != nil { + t.Fatalf("archived asset deletion failed: %v", err) + } + for _, check := range []struct { + model any + want int64 + }{ + {&model.Asset{}, 0}, + {&model.Resource{}, 0}, + {&model.CanvasSnapshotResource{}, 0}, + {&model.CanvasSnapshot{}, 1}, + {&model.ResourceDeletionJob{}, 1}, + } { + var count int64 + if err := db.Model(check.model).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != check.want { + t.Fatalf("%T count=%d, want %d", check.model, count, check.want) + } + } +} diff --git a/backend/internal/app/resource_batch_delete_test.go b/backend/internal/app/resource_batch_delete_test.go new file mode 100644 index 000000000..594a75e59 --- /dev/null +++ b/backend/internal/app/resource_batch_delete_test.go @@ -0,0 +1,86 @@ +package app + +import ( + "testing" + "time" + + "infinite-canvas/backend/internal/model" +) + +func TestPurgeAssetsBatchSharedResourcesAndHistory(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + for _, id := range []string{"batch-shared", "keep-shared", "same-object", "foreign-alias"} { + owner, objectKey := "user-1", id+".png" + if id == "foreign-alias" { + owner, objectKey = "user-2", "same-object.png" + } + if err := db.Create(&model.Resource{ID: id, UserID: owner, Provider: "unsupported-test-provider", ObjectKey: objectKey, Status: model.ResourceStatusReady}).Error; err != nil { + t.Fatal(err) + } + } + for _, record := range []any{ + &model.Asset{ID: "first", UserID: "user-1", Status: model.AssetVersionStatusConfirmed, PayloadJSON: `{"resourceIds":["batch-shared","keep-shared","same-object"]}`}, + &model.Asset{ID: "second", UserID: "user-1", Status: model.AssetVersionStatusArchived, PayloadJSON: `{}`}, + &model.AssetVersion{ID: "second-version", AssetID: "second", DefinitionJSON: `{"resourceId":"batch-shared"}`}, + &model.AssetRepresentation{ID: "second-representation", AssetVersionID: "second-version", Role: "image", ResourceID: "batch-shared", MetadataJSON: `{}`}, + &model.Asset{ID: "unselected", UserID: "user-1", PayloadJSON: `{}`}, + &model.AssetVersion{ID: "unselected-version", AssetID: "unselected", DefinitionJSON: `{}`}, + &model.AssetRepresentation{ID: "unselected-representation", AssetVersionID: "unselected-version", Role: "video", MetadataJSON: `{"resourceId":"keep-shared"}`}, + &model.Task{ID: "batch-task", UserID: "user-1", Status: model.TaskStatusRunning, InputJSON: `{"resourceId":"batch-shared"}`}, + &model.CanvasProject{ID: "batch-canvas", UserID: "user-1", PayloadJSON: `{"resourceId":"batch-shared"}`}, + &model.CanvasSnapshot{ID: "batch-snapshot", CanvasID: "batch-canvas", UserID: "user-1", Revision: 1, PayloadJSON: `{}`, CreatedAt: time.Now()}, + &model.CanvasSnapshotResource{SnapshotID: "batch-snapshot", ResourceID: "batch-shared"}, + } { + if err := db.Create(record).Error; err != nil { + t.Fatal(err) + } + } + // Force an error late in the resource transaction, after asset and outbox writes. + if err := db.Exec("CREATE TRIGGER fail_batch_resource_delete BEFORE DELETE ON resources BEGIN SELECT RAISE(ABORT, 'forced failure'); END;").Error; err != nil { + t.Fatal(err) + } + if err := svc.PurgeUserAssets("user-1", []string{"first", "second"}); err == nil { + t.Fatal("expected rollback") + } + assertCount := func(record any, want int64) { + t.Helper() + var count int64 + if err := db.Model(record).Count(&count).Error; err != nil || count != want { + t.Fatalf("%T count=%d want=%d err=%v", record, count, want, err) + } + } + assertCount(&model.Asset{}, 3) + assertCount(&model.Resource{}, 4) + assertCount(&model.ResourceDeletionJob{}, 0) + assertCount(&model.CanvasSnapshotResource{}, 1) + assertCount(&model.AssetVersion{}, 2) + assertCount(&model.AssetRepresentation{}, 2) + if err := db.Exec("DROP TRIGGER fail_batch_resource_delete").Error; err != nil { + t.Fatal(err) + } + if err := svc.PurgeUserAssets("user-1", []string{"first", " second ", "first"}); err != nil { + t.Fatal(err) + } + assertCount(&model.Asset{}, 1) + assertCount(&model.Resource{}, 2) + assertCount(&model.ResourceDeletionJob{}, 1) + assertCount(&model.CanvasSnapshotResource{}, 0) + assertCount(&model.CanvasSnapshot{}, 1) + assertCount(&model.Task{}, 1) + assertCount(&model.CanvasProject{}, 1) + assertCount(&model.AssetVersion{}, 1) + assertCount(&model.AssetRepresentation{}, 1) + var job model.ResourceDeletionJob + if err := db.First(&job).Error; err != nil { + t.Fatal(err) + } + if job.ResourceID != "batch-shared" { + t.Fatalf("wrong physical object queued: %s", job.ResourceID) + } + for _, id := range []string{"keep-shared", "foreign-alias"} { + var resource model.Resource + if err := db.First(&resource, "id = ?", id).Error; err != nil { + t.Fatalf("shared resource %s lost: %v", id, err) + } + } +} diff --git a/backend/internal/app/resource_delete.go b/backend/internal/app/resource_delete.go index 55d4d3498..44a8cbeab 100644 --- a/backend/internal/app/resource_delete.go +++ b/backend/internal/app/resource_delete.go @@ -18,25 +18,54 @@ import ( qiniuAuth "github.com/qiniu/go-sdk/v7/auth" qiniuStorage "github.com/qiniu/go-sdk/v7/storage" + "gorm.io/gorm" ) -func (s *Service) deleteUserAssetWithResources(userID string, assetID string) error { - asset, err := s.repo.AssetForUser(userID, assetID) +func (s *Service) deleteUserAssetWithResources(userID string, assetID string, purge bool) error { + return s.deleteUserAssetsWithResources(userID, []string{assetID}, purge) +} + +func (s *Service) deleteUserAssetsWithResources(userID string, ids []string, purge bool) error { + if strings.TrimSpace(userID) == "" || len(ids) == 0 || len(ids) > 1000 { + return BadAuthRequest("每次删除须提供 1–1000 个素材 ID 和有效用户") + } + assetIDs := make([]string, 0, len(ids)) + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + return BadAuthRequest("素材 ID 不能为空") + } + if _, exists := seen[id]; !exists { + seen[id] = struct{}{} + assetIDs = append(assetIDs, id) + } + } + assetRecords, err := s.repo.AssetsForUserIDs(userID, assetIDs) if err != nil { return err } - assetReferences, err := s.repo.AssetBusinessReferences(userID, assetID) - if err != nil { - return err + if len(assetRecords) != len(assetIDs) { + return NotFound("素材不存在或无权删除") } - versions, representations, err := s.repo.AssetResourceRecords(assetID) + deleteReferencedResources := purge + if !purge { + // 内部普通删除仍为单素材保护路径;用户显式删除统一走 purge。 + if len(assetRecords) != 1 { + return BadAuthRequest("普通删除仅支持单个素材") + } + deleteReferencedResources = assetRecords[0].Status == model.AssetVersionStatusArchived + } + versions, representations, err := s.repo.AssetsResourceRecords(assetIDs) if err != nil { return err } resourceIDs := map[string]struct{}{} - if err := collectOwnedAssetDocumentReferences(asset.PayloadJSON, resourceIDs); err != nil { - return BadAuthRequest("素材数据无法解析,已停止删除以避免误删文件") + for _, asset := range assetRecords { + if err := collectOwnedAssetDocumentReferences(asset.PayloadJSON, resourceIDs); err != nil { + return BadAuthRequest("素材数据无法解析,已停止删除以避免误删文件") + } } for _, version := range versions { if err := collectOwnedAssetDocumentReferences(version.DefinitionJSON, resourceIDs); err != nil { @@ -64,12 +93,24 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er ownedIDSet[resource.ID] = struct{}{} } - usages := make([]resourceUsage, 0, len(assetReferences)) - for _, reference := range assetReferences { - usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + usages := make([]resourceUsage, 0) + if !deleteReferencedResources { + assetReferences, referenceErr := s.repo.AssetBusinessReferences(userID, assetIDs[0]) + if referenceErr != nil { + return referenceErr + } + for _, reference := range assetReferences { + usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + } } if len(ownedIDs) > 0 { - snapshot, snapshotErr := s.repo.ResourceReferenceSnapshot(userID, assetID, ownedIDs) + var snapshot repository.ResourceReferenceSnapshot + var snapshotErr error + if deleteReferencedResources { + snapshot, snapshotErr = s.repo.OtherAssetResourceReferences(userID, assetIDs) + } else { + snapshot, snapshotErr = s.repo.ResourceReferenceSnapshot(userID, assetIDs[0], ownedIDs) + } if snapshotErr != nil { return snapshotErr } @@ -80,7 +121,9 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er sharedAssetResourceIDs[reference.ResourceID] = struct{}{} continue } - usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + if !deleteReferencedResources { + usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + } } } for _, document := range snapshot.Documents { @@ -106,7 +149,9 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er } continue } - usages = append(usages, resourceUsage{Kind: document.Kind, ID: document.ID, Title: document.Title}) + if !deleteReferencedResources { + usages = append(usages, resourceUsage{Kind: document.Kind, ID: document.ID, Title: document.Title}) + } } } if len(sharedAssetResourceIDs) > 0 { @@ -139,7 +184,10 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er deletionJobs := resourceDeletionJobs(userID, physicalObjects) // 业务记录和 Outbox 必须在同一事务提交。事务失败时物理文件完全不动; // 提交成功后由幂等 worker 清理,进程退出或对象存储暂时失败都可继续重试。 - if err := s.repo.DeleteAssetAndResources(userID, assetID, ownedIDs, deletionJobs); err != nil { + if err := s.repo.DeleteAssetsAndResources(userID, assetIDs, ownedIDs, deletionJobs, deleteReferencedResources); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return NotFound("素材不存在或无权删除") + } if errors.Is(err, repository.ErrCanvasHistoryResourceReferenced) { return BadAuthRequest("素材仍被画布历史版本引用,已保留文件") } diff --git a/backend/internal/app/resource_delete_test.go b/backend/internal/app/resource_delete_test.go index f2ab072f9..77af112db 100644 --- a/backend/internal/app/resource_delete_test.go +++ b/backend/internal/app/resource_delete_test.go @@ -142,7 +142,7 @@ func TestDeleteAssetDatabaseFailureLeavesPhysicalObjectAndNoOutbox(t *testing.T) t.Fatal(err) } - if err := svc.deleteUserAssetWithResources("user-1", "asset-1"); err == nil { + if err := svc.deleteUserAssetWithResources("user-1", "asset-1", false); err == nil { t.Fatal("forced database failure should be returned") } if _, err := os.Stat(resourcePath); err != nil { @@ -298,6 +298,49 @@ func TestDeleteGeneratedAssetTaskReferences(t *testing.T) { } } +func TestPurgeConfirmedAssetIgnoresTaskReferences(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + payload := `{"url":"/api/resources/purge-confirmed-resource/file"}` + resource := model.Resource{ + ID: "purge-confirmed-resource", UserID: "user-1", Provider: "unsupported-test-provider", + ObjectKey: "purge-confirmed.png", Status: model.ResourceStatusReady, + } + asset := model.Asset{ + ID: "purge-confirmed-asset", UserID: "user-1", Title: "本地已在回收站的素材", + Status: model.AssetVersionStatusConfirmed, PayloadJSON: payload, + } + task := model.Task{ + ID: "purge-confirmed-task", UserID: "user-1", Prompt: "将图片拆分为可独立编辑的图层", + Status: model.TaskStatusRunning, InputJSON: payload, ResultJSON: payload, + } + for _, item := range []any{&resource, &asset, &task} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + if err := svc.PurgeUserAsset("user-1", asset.ID); err != nil { + t.Fatalf("PurgeUserAsset() error = %v", err) + } + for _, check := range []struct { + model any + want int64 + }{ + {&model.Asset{}, 0}, + {&model.Resource{}, 0}, + {&model.ResourceDeletionJob{}, 1}, + {&model.Task{}, 1}, + } { + var count int64 + if err := db.Model(check.model).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != check.want { + t.Fatalf("%T count=%d, want %d", check.model, count, check.want) + } + } +} + func TestResourceDeletionWorkerRemovesObjectAndCompletesOutbox(t *testing.T) { svc, db, dataDir := newResourceDeletionTestService(t) objectKey := "users/user-1/image/queued.png" @@ -330,7 +373,7 @@ func TestResourceDeletionWorkerRemovesObjectAndCompletesOutbox(t *testing.T) { } } -func TestExpiredArchivedAssetCleanupRespectsCanvasReferencesAndUsesDeletionOutbox(t *testing.T) { +func TestExpiredArchivedAssetCleanupDeletesReferencedAssetAndUsesDeletionOutbox(t *testing.T) { svc, db, _ := newResourceDeletionTestService(t) old := time.Now().Add(-45 * 24 * time.Hour) resource := model.Resource{ @@ -363,25 +406,8 @@ func TestExpiredArchivedAssetCleanupRespectsCanvasReferencesAndUsesDeletionOutbo if err := db.Model(&model.ResourceDeletionJob{}).Where("resource_id = ?", resource.ID).Count(&jobCount).Error; err != nil { t.Fatal(err) } - if assetCount != 1 || resourceCount != 1 || jobCount != 0 { - t.Fatalf("referenced archived asset cleanup changed data: asset=%d resource=%d jobs=%d", assetCount, resourceCount, jobCount) - } - - if err := db.Delete(&model.CanvasProject{}, "id = ? AND user_id = ?", canvas.ID, canvas.UserID).Error; err != nil { - t.Fatal(err) - } - svc.cleanupExpiredArchivedAssets() - if err := db.Model(&model.Asset{}).Where("id = ?", asset.ID).Count(&assetCount).Error; err != nil { - t.Fatal(err) - } - if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&resourceCount).Error; err != nil { - t.Fatal(err) - } - if err := db.Model(&model.ResourceDeletionJob{}).Where("resource_id = ?", resource.ID).Count(&jobCount).Error; err != nil { - t.Fatal(err) - } if assetCount != 0 || resourceCount != 0 || jobCount != 1 { - t.Fatalf("unreferenced archived asset did not use deletion outbox: asset=%d resource=%d jobs=%d", assetCount, resourceCount, jobCount) + t.Fatalf("referenced archived asset was not force deleted through outbox: asset=%d resource=%d jobs=%d", assetCount, resourceCount, jobCount) } } diff --git a/backend/internal/canvas/host.go b/backend/internal/canvas/host.go index 1cc92c062..b35d1d71e 100644 --- a/backend/internal/canvas/host.go +++ b/backend/internal/canvas/host.go @@ -15,7 +15,8 @@ type Host interface { WithStorageLock(fn func() error) error StructuredQuota(userID, kind string, creating bool, deltaBytes int64) error StructuredReplacementQuota(userID, kind string, count int, bytes int64) error - DeleteUserAssetWithResources(userID, assetID string) error + DeleteUserAssetWithResources(userID, assetID string, purge bool) error + PurgeUserAssetsWithResources(userID string, assetIDs []string) error RecordActivity(userID, event string, count int) } @@ -37,7 +38,8 @@ func (nopHost) WithStorageLock(fn func() error) error { } func (nopHost) StructuredQuota(string, string, bool, int64) error { return nil } func (nopHost) StructuredReplacementQuota(string, string, int, int64) error { return nil } -func (nopHost) DeleteUserAssetWithResources(string, string) error { return nil } +func (nopHost) DeleteUserAssetWithResources(string, string, bool) error { return nil } +func (nopHost) PurgeUserAssetsWithResources(string, []string) error { return nil } func (nopHost) RecordActivity(string, string, int) {} type Service struct { diff --git a/backend/internal/canvas/user_data.go b/backend/internal/canvas/user_data.go index 151876d6e..ad2687ac5 100644 --- a/backend/internal/canvas/user_data.go +++ b/backend/internal/canvas/user_data.go @@ -128,7 +128,17 @@ func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataS func (s *Service) DeleteUserAsset(userID string, id string) error { return s.host.WithStorageLock(func() error { - return s.host.DeleteUserAssetWithResources(userID, id) + return s.host.DeleteUserAssetWithResources(userID, id, false) + }) +} + +func (s *Service) PurgeUserAsset(userID string, id string) error { + return s.PurgeUserAssets(userID, []string{id}) +} + +func (s *Service) PurgeUserAssets(userID string, ids []string) error { + return s.host.WithStorageLock(func() error { + return s.host.PurgeUserAssetsWithResources(userID, ids) }) } diff --git a/backend/internal/handler/asset_delete_test.go b/backend/internal/handler/asset_delete_test.go new file mode 100644 index 000000000..9225100ff --- /dev/null +++ b/backend/internal/handler/asset_delete_test.go @@ -0,0 +1,126 @@ +package handler + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "infinite-canvas/backend/internal/auth" + "infinite-canvas/backend/internal/database" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestAssetBatchDeleteHTTP(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := database.MigrateSchema(db); err != nil { + t.Fatal(err) + } + for _, record := range []any{ + &model.User{ID: "batch-user", Username: "batch-user", Email: "batch@example.invalid", Role: model.UserRoleUser, Status: model.UserStatusActive}, + &model.AuthSession{ID: "batch-session", UserID: "batch-user", TokenHash: auth.HashToken("test-token"), ExpiresAt: time.Now().Add(time.Hour)}, + &model.Asset{ID: "foreign", UserID: "other-user", PayloadJSON: `{}`}, + } { + if err := db.Create(record).Error; err != nil { + t.Fatal(err) + } + } + ids := make([]string, 100) + for i := range ids { + ids[i] = fmt.Sprintf("batch-%03d", i) + if err := db.Create(&model.Asset{ID: ids[i], UserID: "batch-user", Status: model.AssetVersionStatusConfirmed, PayloadJSON: `{}`}).Error; err != nil { + t.Fatal(err) + } + } + router := gin.New() + RegisterUserDataRoutes(router.Group("/api"), service.New(repository.New(db), t.TempDir())) + call := func(method, path, body string, authenticated bool) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + r.Header.Set("Content-Type", "application/json") + if authenticated { + r.AddCookie(&http.Cookie{Name: service.SessionCookieName, Value: "batch-session.test-token"}) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, r) + return w + } + assertAssets := func(want int64) { + t.Helper() + var count int64 + if err := db.Model(&model.Asset{}).Count(&count).Error; err != nil || count != want { + t.Fatalf("asset count=%d want=%d err=%v", count, want, err) + } + } + path := "/api/assets/batch-delete" + if w := call(http.MethodPost, path, `["batch-000"]`, false); w.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated: %d %s", w.Code, w.Body.String()) + } + for _, body := range []string{`[]`, `null`, `{ "ids": ["batch-000"] }`, `["batch-000", ""]`, `[1]`} { + if w := call(http.MethodPost, path, body, true); w.Code != http.StatusBadRequest { + t.Fatalf("invalid body %s: %d %s", body, w.Code, w.Body.String()) + } + assertAssets(101) + } + for _, badID := range []string{"foreign", "missing"} { + w := call(http.MethodPost, path, `["batch-000","`+badID+`"]`, true) + if w.Code != http.StatusNotFound { + t.Fatalf("ownership/missing %s: %d %s", badID, w.Code, w.Body.String()) + } + assertAssets(101) + } + body, _ := json.Marshal(ids) + if err := db.Exec("CREATE TRIGGER fail_batch_delete BEFORE DELETE ON assets WHEN OLD.id = 'batch-099' BEGIN SELECT RAISE(ABORT, 'forced failure'); END;").Error; err != nil { + t.Fatal(err) + } + if w := call(http.MethodPost, path, string(body), true); w.Code != http.StatusInternalServerError { + t.Fatalf("database failure: %d %s", w.Code, w.Body.String()) + } + assertAssets(101) + if err := db.Exec("DROP TRIGGER fail_batch_delete").Error; err != nil { + t.Fatal(err) + } + w := call(http.MethodPost, path, string(body), true) + var response struct { + Code int `json:"code"` + Data struct { + IDs []string `json:"ids"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || w.Code != http.StatusOK || response.Code != 0 || len(response.Data.IDs) != 100 { + t.Fatalf("batch response: %d %s err=%v", w.Code, w.Body.String(), err) + } + assertAssets(1) + + // The existing DELETE endpoint uses the same purge policy for non-archived assets. + resource := model.Resource{ID: "single-resource", UserID: "batch-user", Provider: "unsupported-test-provider", ObjectKey: "single.png"} + payload := `{"url":"/api/resources/single-resource/file"}` + for _, record := range []any{ + &resource, + &model.Asset{ID: "single", UserID: "batch-user", Status: model.AssetVersionStatusConfirmed, PayloadJSON: payload}, + &model.Task{ID: "running-task", UserID: "batch-user", Status: model.TaskStatusRunning, InputJSON: payload}, + } { + if err := db.Create(record).Error; err != nil { + t.Fatal(err) + } + } + if w := call(http.MethodDelete, "/api/assets/single", "", true); w.Code != http.StatusOK { + t.Fatalf("single referenced delete: %d %s", w.Code, w.Body.String()) + } + assertAssets(1) +} diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index 211e6c62a..2d6f194a6 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -1090,6 +1090,65 @@ paths: responses: "200": $ref: "#/components/responses/Envelope" + /assets/batch-delete: + post: + tags: [assets] + summary: 批量彻底删除当前用户素材 + description: 顶层 JSON 数组,一次请求、一个数据库事务。所有 ID 必须属于当前用户,不受任务、画布或历史引用阻塞;共享文件保留,其余通过 Outbox 异步清理,失败重试。任一素材不存在、越权或数据库写入失败时整批不删除。 + requestBody: + required: true + content: + application/json: + schema: + type: array + minItems: 1 + maxItems: 1000 + items: + type: string + minLength: 1 + example: ["asset-1", "asset-2"] + responses: + "200": + description: 成功信封,data.ids 返回本次请求的素材 ID 数组;物理文件清理由后台执行。 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + required: [ids] + properties: + ids: + type: array + items: { type: string } + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + "404": + $ref: "#/components/responses/Error" + "500": + $ref: "#/components/responses/Error" + /assets/{id}: + parameters: + - in: path + name: id + required: true + schema: { type: string } + delete: + tags: [assets] + summary: 彻底删除当前用户素材 + description: 不依赖素材 archived 状态,也不受画布、任务、项目或历史快照引用阻塞;其他素材共享的物理对象仍保留。 + responses: + "200": + $ref: "#/components/responses/Envelope" + "401": + $ref: "#/components/responses/Error" + "404": + $ref: "#/components/responses/Error" components: parameters: AgentRunID: diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index d5800435a..e66d6e123 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -18,6 +18,24 @@ import ( ) func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { + r.POST("/assets/batch-delete", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) + var ids []string + if err := c.ShouldBindJSON(&ids); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + if err := svc.PurgeUserAssets(user.ID, ids); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"ids": ids}) + }) r.POST("/assets/batch", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -586,7 +604,7 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - if err := svc.DeleteUserAsset(user.ID, c.Param("id")); err != nil { + if err := svc.PurgeUserAsset(user.ID, c.Param("id")); err != nil { failService(c, err) return } diff --git a/backend/internal/handler/user_data_page_test.go b/backend/internal/handler/user_data_page_test.go index 88cdf4837..c04a6bc1c 100644 --- a/backend/internal/handler/user_data_page_test.go +++ b/backend/internal/handler/user_data_page_test.go @@ -13,9 +13,10 @@ func TestUserDataIncrementalRoutesAreRegistered(t *testing.T) { router := gin.New() RegisterUserDataRoutes(router.Group("/api"), &service.Service{}) wanted := map[string]bool{ - "GET /api/canvas-projects": false, - "GET /api/canvas-projects/:id": false, - "POST /api/assets/batch": false, + "GET /api/canvas-projects": false, + "GET /api/canvas-projects/:id": false, + "POST /api/assets/batch": false, + "POST /api/assets/batch-delete": false, } for _, route := range router.Routes() { key := route.Method + " " + route.Path diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index dd68511e6..6903a733d 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -1156,7 +1156,7 @@ func (r *Repository) UpsertAsset(asset *model.Asset) error { } func (r *Repository) DeleteAsset(userID string, id string) error { - return r.DeleteAssetAndResources(userID, id, nil, nil) + return r.DeleteAssetAndResources(userID, id, nil, nil, false) } func (r *Repository) FindExpiredArchivedAssets(cutoff time.Time, limit int) ([]model.Asset, error) { diff --git a/backend/internal/repository/resource_reference.go b/backend/internal/repository/resource_reference.go index 9700f5b5b..d623f1527 100644 --- a/backend/internal/repository/resource_reference.go +++ b/backend/internal/repository/resource_reference.go @@ -10,6 +10,7 @@ import ( "infinite-canvas/backend/internal/model" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // ResourceReferenceDocument 是资源删除校验使用的只读业务文档快照。 @@ -36,8 +37,12 @@ type ResourceReferenceSnapshot struct { } func (r *Repository) AssetResourceRecords(assetID string) ([]model.AssetVersion, []model.AssetRepresentation, error) { + return r.AssetsResourceRecords([]string{assetID}) +} + +func (r *Repository) AssetsResourceRecords(assetIDs []string) ([]model.AssetVersion, []model.AssetRepresentation, error) { var versions []model.AssetVersion - if err := r.db.Where("asset_id = ?", assetID).Find(&versions).Error; err != nil { + if err := r.db.Where("asset_id IN ?", assetIDs).Find(&versions).Error; err != nil { return nil, nil, err } if len(versions) == 0 { @@ -54,6 +59,35 @@ func (r *Repository) AssetResourceRecords(assetID string) ([]model.AssetVersion, return versions, representations, nil } +// OtherAssetResourceReferences 只查询整批删除范围之外的素材,不扫描任务和画布。 +func (r *Repository) OtherAssetResourceReferences(userID string, excludedAssetIDs []string) (ResourceReferenceSnapshot, error) { + snapshot := ResourceReferenceSnapshot{} + var assets []model.Asset + if err := r.db.Where("user_id = ? AND id NOT IN ?", userID, excludedAssetIDs).Find(&assets).Error; err != nil { + return snapshot, err + } + ids := make([]string, 0, len(assets)) + for _, asset := range assets { + ids = append(ids, asset.ID) + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "素材", ID: asset.ID, PrimaryJSON: asset.PayloadJSON}) + } + if len(ids) == 0 { + return snapshot, nil + } + versions, representations, err := r.AssetsResourceRecords(ids) + if err != nil { + return snapshot, err + } + for _, version := range versions { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "素材", ID: version.ID, PrimaryJSON: version.DefinitionJSON}) + } + for _, representation := range representations { + snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "素材", ID: representation.ID, ResourceID: representation.ResourceID}) + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "素材", ID: representation.ID, PrimaryJSON: representation.MetadataJSON}) + } + return snapshot, nil +} + func (r *Repository) ResourcesForUserIDs(userID string, resourceIDs []string) ([]model.Resource, error) { if len(resourceIDs) == 0 { return []model.Resource{}, nil @@ -352,12 +386,42 @@ func (r *Repository) AssetBusinessReferences(userID string, assetID string) ([]R return result, nil } -func (r *Repository) DeleteAssetAndResources(userID string, assetID string, resourceIDs []string, deletionJobs []model.ResourceDeletionJob) error { +func (r *Repository) DeleteAssetAndResources(userID string, assetID string, resourceIDs []string, deletionJobs []model.ResourceDeletionJob, deleteReferencedResources bool) error { + return r.DeleteAssetsAndResources(userID, []string{assetID}, resourceIDs, deletionJobs, deleteReferencedResources) +} + +func (r *Repository) DeleteAssetsAndResources(userID string, assetIDs []string, resourceIDs []string, deletionJobs []model.ResourceDeletionJob, deleteReferencedResources bool) error { return r.db.Transaction(func(tx *gorm.DB) error { - if err := New(tx).RequireNoCanvasHistoryReferences(resourceIDs); err != nil { + var ownedAssets []model.Asset + query := tx.Where("user_id = ? AND id IN ?", userID, assetIDs).Order("id") + if r.Dialect() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + if err := query.Find(&ownedAssets).Error; err != nil { return err } - versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id = ?", assetID) + if len(assetIDs) == 0 || len(ownedAssets) != len(assetIDs) { + return gorm.ErrRecordNotFound + } + if deleteReferencedResources { + if len(resourceIDs) > 0 { + // 与历史快照写入串行化,避免清除索引后又插入外键引用。 + if r.Dialect() == "postgres" { + var resources []model.Resource + if err := tx.Select("id").Where("id IN ?", resourceIDs).Order("id").Clauses(clause.Locking{Strength: "UPDATE"}).Find(&resources).Error; err != nil { + return err + } + } + if err := tx.Where("resource_id IN ?", resourceIDs).Delete(&model.CanvasSnapshotResource{}).Error; err != nil { + return err + } + } + } else { + if err := New(tx).RequireNoCanvasHistoryReferences(resourceIDs); err != nil { + return err + } + } + versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id IN ?", assetIDs) if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.ShotAssetReference{}).Error; err != nil { return err } @@ -367,16 +431,16 @@ func (r *Repository) DeleteAssetAndResources(userID string, assetID string, reso if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.AssetRepresentation{}).Error; err != nil { return err } - if err := tx.Where("asset_id = ?", assetID).Delete(&model.ProjectAssetLink{}).Error; err != nil { + if err := tx.Where("asset_id IN ?", assetIDs).Delete(&model.ProjectAssetLink{}).Error; err != nil { return err } - if err := tx.Where("resolved_asset_id = ?", assetID).Delete(&model.ProjectAssetCandidate{}).Error; err != nil { + if err := tx.Where("resolved_asset_id IN ?", assetIDs).Delete(&model.ProjectAssetCandidate{}).Error; err != nil { return err } - if err := tx.Where("asset_id = ?", assetID).Delete(&model.AssetVersion{}).Error; err != nil { + if err := tx.Where("asset_id IN ?", assetIDs).Delete(&model.AssetVersion{}).Error; err != nil { return err } - if err := tx.Delete(&model.Asset{}, "id = ? AND user_id = ?", assetID, userID).Error; err != nil { + if err := tx.Delete(&model.Asset{}, "id IN ? AND user_id = ?", assetIDs, userID).Error; err != nil { return err } if len(deletionJobs) > 0 { diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 3a74a8ce9..f900b8416 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -11,6 +11,22 @@ description: 信封、错误码与 OpenAPI 出站模型服务域名解析失败返回 HTTP / `code: 502`、`reason: upstream_dns_failed`,不归类为用户参数错误。公开消息不包含上游域名、DNS 服务器或请求 URL;原始解析错误仅保留在内部错误链。此分类不会自动重试、切换渠道或放宽 SSRF 校验;上下文取消仍按取消处理。 +## 素材彻底删除 + +`DELETE /assets/:id` 表示用户确认后的彻底删除,不依赖服务端当前记录是否已经同步为 `archived`。接口仍校验登录用户和素材归属,但不会因画布、任务、项目或历史快照仍有引用而拒绝;事务会删除素材及对应历史资源索引,并通过删除 Outbox 清理未被其他素材复用的物理对象。其他素材共享的同一物理对象继续保留。移入回收站仍通过素材写入接口把状态更新为 `archived`,不能用 DELETE 代替可恢复归档。 + +素材页及素材选择器的批量彻底删除、清空回收站统一调用 `POST /assets/batch-delete`,请求体直接为 JSON 数组(不是 `{ ids: [...] }`): + +```json +["asset-1", "asset-2"] +``` + +- 每次 1–1000 项,单个请求体最多 256 KiB;ID 去除首尾空白并去重,空 ID / 非数组返回 400,任一 ID 不存在或不属于当前用户返回 404,整批不删除。 +- 成功信封 `data.ids` 为请求传入的数组。前端等待服务端成功后一次更新本地集合并冲刷持久化;失败不回退为逐条 DELETE,不先移除本地记录。 +- 整批素材、版本、表现、业务关联、待删资源及对应历史资源索引,与物理删除 Outbox 在同一数据库事务中提交,数据库失败整批回滚。共享检查排除整批被删素材,避免两条互相共享的选中素材残留文件。 +- 物理空间由后台删除队列异步释放,失败保留任务重试;接口成功不代表对象存储已经清理完成。未选中素材使用的资源,以及其他 Resource 行(包括其他用户)复用的物理对象继续保留。 +- 旧画布 / 任务 / 历史快照记录本身保留,但被删媒体链接将失效。此操作不可恢复;没有数据库结构迁移。 + ## 站点皮肤主按钮 ### 画布配置与 Live2D(外观合同版本 9) diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index f4c5c98a6..364104f1e 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,17 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 回收站彻底删除与画布工具引用 + +- [ ] 素材卡片选择框恢复左上角,徽章为选择框和更多菜单让位;批量操作条取消吸顶覆盖,并与卡片网格保留间距。需在真实登录页面检查多选边框、长列表滚动、窄屏换行和明暗主题;自动浏览器访问被登录页拦截,样式约束测试不代替视觉验收。 +- [x] 新增 `POST /api/assets/batch-delete`,顶层 JSON 数组传入素材 ID;素材页及选择器的批量删除、清空回收站只发一次请求,后端批量事务处理,不按 ID 逐次提交。整批归属检查、事务失败回滚、同批共享资源回收与未选共享资源保留有专项覆盖。 +- [x] `DELETE /api/assets/:id` 明确表示彻底删除,不再依赖服务端素材状态判断是否位于回收站;即使本地已归档但服务端仍暂存为 `confirmed`,也不会受画布、任务或历史快照引用阻塞。事务会移除对应历史资源索引并通过删除 Outbox 释放未被其他素材复用的物理文件;内部普通删除仍保留引用保护。 +- [x] 画布提示词中的工具引用按“工具类型 + 工具 ID”区分,并使用最新提示词原子追加;相同数字 ID 的不同工具不会互相覆盖或把前一个标签回退成编码令牌。后端专项测试、前端工具集成测试和前端类型检查通过。 +- [x] 删除完成后退出用户数据同步队列,再异步刷新素材库与素材选择器分页缓存,避免删除与列表查询互相等待而无法关闭确认弹窗。去掉选择器删除后的重复刷新;刷新失败保留查询错误并记录告警,不误报删除失败。8 项批量删除与工具栏专项测试通过,包含真实 QueryObserver、分页加载与同步队列链路,以及刷新延迟、刷新失败场景(仅模拟 HTTP 响应)。 +- [ ] 在真实素材页及素材选择器中分别确认单条删除、批量删除和清空回收站:删除成功后确认弹窗关闭,列表刷新;限速或模拟列表请求失败时确认弹窗不被卡住,素材选择器外层窗口保持打开。 +- [ ] 在真实素材页分别验证单个彻底删除、选择 100 条后批量删除、清空回收站及过期自动清理;网络面板应显示单个批量 POST,body 为数组。确认后台清理完成后空间释放、原画布或任务中的旧媒体引用按预期失效、其他素材复用的文件仍可访问;失败时不出现部分删除。 +- [ ] 在真实画布快速连续选择两个不同工具,并覆盖相同数字 ID、中文标签、同类型替换和多个运镜工具,确认两个标签均正常显示、保存刷新后不乱码。 + ## 画布 Agent 配置与 Live2D - [ ] 使用约 50 MiB 的真实模型 ZIP 验收上传和预览;ZIP 上限 128 MiB,解压累计 384 MiB、单文件 128 MiB,超限须拒绝。 diff --git a/web/src/components/assets/asset-library-picker-modal.tsx b/web/src/components/assets/asset-library-picker-modal.tsx index 84c68951d..b30b8d527 100644 --- a/web/src/components/assets/asset-library-picker-modal.tsx +++ b/web/src/components/assets/asset-library-picker-modal.tsx @@ -13,7 +13,7 @@ import { PaginationBar } from "@/components/layout/workspace-page"; import { cn } from "@/lib/utils"; import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types"; import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store"; -import { deleteAssetWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync"; +import { deleteAssetsWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync"; export type AssetPickerMediaKind = "image" | "video" | "audio" | "text"; @@ -291,14 +291,13 @@ export function AssetLibraryPickerModal({ if (!archivedSelectedIds.length) return; setWorking(true); try { - for (const id of archivedSelectedIds) await deleteAssetWithRemoteSync(id); + await deleteAssetsWithRemoteSync(archivedSelectedIds); setSelected(new Set()); message.success(`已彻底删除 ${archivedSelectedIds.length} 个素材`); } catch (err) { message.error(err instanceof Error ? err.message : "删除失败"); } finally { setWorking(false); - if (remoteEnabled) void remoteQuery.refetch(); } }; @@ -307,7 +306,7 @@ export function AssetLibraryPickerModal({ if (!toDelete.length) return; setWorking(true); try { - for (const item of toDelete) await deleteAssetWithRemoteSync(item.id); + await deleteAssetsWithRemoteSync(toDelete.map((item) => item.id)); setSelected(new Set()); message.success(`已删除${remoteEnabled ? "当前页" : "回收站"} ${toDelete.length} 个素材`); setCategory("all"); @@ -315,7 +314,6 @@ export function AssetLibraryPickerModal({ message.error(err instanceof Error ? err.message : "清空回收站失败"); } finally { setWorking(false); - if (remoteEnabled) void remoteQuery.refetch(); } }; @@ -532,7 +530,7 @@ export function AssetLibraryPickerModal({