From ae0fddaac5cc7245b3d1a8b0c429c276633966c4 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 29 Aug 2026 15:40:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=AD=98=E5=82=A8=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20-=20=E6=94=AF=E6=8C=81=E6=89=B9=E9=87=8F=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E6=A0=A1=E9=AA=8C=E4=B8=8E=E4=BA=8B=E5=8A=A1=E5=8C=96?= =?UTF-8?q?=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapted from ddcat-ai/open-ai-canvas#339 Original commit: fcdbe2aa833f31f683f709b7f409a0534ed78e20 Refs #339 Co-authored-by: 宇熙 <101980758+yuxiwl@users.noreply.github.com> --- backend/internal/handler/admin_storage.go | 19 ++ .../repository/admin_storage_delete.go | 105 +++++++ .../internal/service/admin_storage_delete.go | 262 ++++++++++++++++++ .../service/admin_storage_delete_test.go | 170 ++++++++++++ docs/content/docs/progress/pending-test.mdx | 13 + .../components/storage-resources-panel.tsx | 67 ++++- web/src/services/api/admin-storage.ts | 21 ++ 7 files changed, 649 insertions(+), 8 deletions(-) create mode 100644 backend/internal/repository/admin_storage_delete.go create mode 100644 backend/internal/service/admin_storage_delete.go create mode 100644 backend/internal/service/admin_storage_delete_test.go diff --git a/backend/internal/handler/admin_storage.go b/backend/internal/handler/admin_storage.go index 79f3cfdf3..f3f02c406 100644 --- a/backend/internal/handler/admin_storage.go +++ b/backend/internal/handler/admin_storage.go @@ -9,6 +9,25 @@ import ( ) func RegisterAdminStorageRoutes(r *gin.RouterGroup, svc *service.Service) { + r.POST("/admin/resources/delete", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var req service.AdminResourceDeleteRequest + if err := c.ShouldBindJSON(&req); err != nil { + failService(c, service.BadAuthRequest("删除资源请求无效")) + return + } + result, err := svc.DeleteAdminResources(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + r.GET("/admin/storage/stats", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/repository/admin_storage_delete.go b/backend/internal/repository/admin_storage_delete.go new file mode 100644 index 000000000..e45fc931a --- /dev/null +++ b/backend/internal/repository/admin_storage_delete.go @@ -0,0 +1,105 @@ +package repository + +import ( + "errors" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ErrAdminResourceDeleteChanged = errors.New("admin resource delete set changed") +var ErrAdminResourceStillReferenced = errors.New("admin resource is still directly referenced") +var ErrAdminResourceAuditMismatch = errors.New("admin resource delete audit mismatch") + +func (r *Repository) AdminResourcesByIDs(ids []string) ([]model.Resource, error) { + if len(ids) == 0 { + return []model.Resource{}, nil + } + var resources []model.Resource + err := r.db.Where("id IN ?", ids).Find(&resources).Error + return resources, err +} + +func (r *Repository) AnnouncementResourceReferences(resourceIDs []string) ([]ResourceDirectReference, error) { + if len(resourceIDs) == 0 { + return []ResourceDirectReference{}, nil + } + var announcements []model.Announcement + if err := r.db.Where("image_resource_id IN ?", resourceIDs).Find(&announcements).Error; err != nil { + return nil, err + } + result := make([]ResourceDirectReference, 0, len(announcements)) + for _, announcement := range announcements { + result = append(result, ResourceDirectReference{Kind: "公告", ID: announcement.ID, Title: announcement.Title, ResourceID: announcement.ImageResourceID}) + } + return result, nil +} + +func (r *Repository) DeleteAdminResources(resources []model.Resource, deletionJobs []model.ResourceDeletionJob, audits []model.AdminAuditEvent) error { + if len(resources) == 0 { + return nil + } + if len(audits) != len(resources) { + return ErrAdminResourceAuditMismatch + } + resourceIDs := make([]string, 0, len(resources)) + for _, resource := range resources { + resourceIDs = append(resourceIDs, resource.ID) + } + return r.db.Transaction(func(tx *gorm.DB) error { + var current []model.Resource + query := tx.Where("id IN ?", resourceIDs) + if r.Dialect() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + if err := query.Find(¤t).Error; err != nil { + return err + } + if len(current) != len(resources) { + return ErrAdminResourceDeleteChanged + } + for _, check := range []struct { + model any + query string + }{ + {&model.Announcement{}, "image_resource_id IN ?"}, + {&model.AssetRepresentation{}, "resource_id IN ?"}, + {&model.VoiceProfile{}, "sample_resource_id IN ?"}, + {&model.ShotArtifact{}, "resource_id IN ?"}, + } { + var count int64 + if err := tx.Model(check.model).Where(check.query, resourceIDs).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return ErrAdminResourceStillReferenced + } + } + if err := tx.Where("resource_id IN ?", resourceIDs).Delete(&model.ArkPrivateAssetBinding{}).Error; err != nil { + return err + } + if err := tx.Where("resource_id IN ?", resourceIDs).Delete(&model.AnnouncementImageDraft{}).Error; err != nil { + return err + } + if len(deletionJobs) > 0 { + if err := tx.Create(&deletionJobs).Error; err != nil { + return err + } + } + deleted := tx.Where("id IN ?", resourceIDs).Delete(&model.Resource{}) + if deleted.Error != nil { + return deleted.Error + } + if deleted.RowsAffected != int64(len(resourceIDs)) { + return ErrAdminResourceDeleteChanged + } + if len(audits) > 0 { + if err := tx.Create(&audits).Error; err != nil { + return err + } + } + return nil + }) +} diff --git a/backend/internal/service/admin_storage_delete.go b/backend/internal/service/admin_storage_delete.go new file mode 100644 index 000000000..b113d426d --- /dev/null +++ b/backend/internal/service/admin_storage_delete.go @@ -0,0 +1,262 @@ +package service + +import ( + "errors" + "sort" + "strings" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +const maxAdminResourceDeleteCount = 100 + +type AdminResourceDeleteRequest struct { + ResourceIDs []string `json:"resourceIds"` +} + +type AdminResourceReferenceView struct { + Kind string `json:"kind"` + ID string `json:"id"` + Title string `json:"title"` +} + +type AdminResourceDeleteBlocked struct { + ID string `json:"id"` + Reason string `json:"reason"` + References []AdminResourceReferenceView `json:"references"` +} + +type AdminResourceDeleteResult struct { + Deleted []string `json:"deleted"` + Blocked []AdminResourceDeleteBlocked `json:"blocked"` +} + +func (s *Service) DeleteAdminResources(actor *model.User, req AdminResourceDeleteRequest) (*AdminResourceDeleteResult, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + resourceIDs, err := normalizeAdminResourceDeleteIDs(req.ResourceIDs) + if err != nil { + return nil, err + } + s.storageMu.Lock() + defer s.storageMu.Unlock() + + resources, err := s.repo.AdminResourcesByIDs(resourceIDs) + if err != nil { + return nil, err + } + resourcesByID := make(map[string]model.Resource, len(resources)) + resourcesByUser := make(map[string][]model.Resource) + for _, resource := range resources { + resourcesByID[resource.ID] = resource + resourcesByUser[resource.UserID] = append(resourcesByUser[resource.UserID], resource) + } + blockedByID := make(map[string]AdminResourceDeleteBlocked) + for _, resourceID := range resourceIDs { + if _, exists := resourcesByID[resourceID]; !exists { + blockedByID[resourceID] = AdminResourceDeleteBlocked{ID: resourceID, Reason: "资源不存在", References: []AdminResourceReferenceView{}} + } + } + for userID, userResources := range resourcesByUser { + userResourceIDs := make([]string, 0, len(userResources)) + for _, resource := range userResources { + userResourceIDs = append(userResourceIDs, resource.ID) + } + snapshot, snapshotErr := s.repo.ResourceReferenceSnapshot(userID, "", userResourceIDs) + if snapshotErr != nil { + return nil, snapshotErr + } + for resourceID, references := range adminResourceReferences(snapshot, userResources) { + blockedByID[resourceID] = AdminResourceDeleteBlocked{ID: resourceID, Reason: "资源仍被业务数据引用", References: references} + } + } + announcementReferences, err := s.repo.AnnouncementResourceReferences(resourceIDs) + if err != nil { + return nil, err + } + for _, reference := range announcementReferences { + blocked := blockedByID[reference.ResourceID] + blocked.ID = reference.ResourceID + blocked.Reason = "资源仍被业务数据引用" + blocked.References = appendUniqueAdminResourceReference(blocked.References, AdminResourceReferenceView{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + blockedByID[reference.ResourceID] = blocked + } + + deletable := make([]model.Resource, 0, len(resources)) + deletableIDs := make([]string, 0, len(resources)) + for _, resourceID := range resourceIDs { + resource, exists := resourcesByID[resourceID] + if !exists { + continue + } + if _, blocked := blockedByID[resourceID]; blocked { + continue + } + if !supportedResourceDeleteProvider(resource.Provider) { + blockedByID[resourceID] = AdminResourceDeleteBlocked{ID: resourceID, Reason: "资源使用了不支持的存储类型,无法安全删除", References: []AdminResourceReferenceView{}} + continue + } + deletable = append(deletable, resource) + deletableIDs = append(deletableIDs, resource.ID) + } + + physicalObjects := map[string]*model.Resource{} + checkedPhysical := map[string]bool{} + for index := range deletable { + resource := &deletable[index] + if strings.TrimSpace(resource.ObjectKey) == "" { + continue + } + identity := resourceStorageIdentity(resource) + if checkedPhysical[identity] { + continue + } + checkedPhysical[identity] = true + sharedCount, countErr := s.repo.ResourceStorageReferenceCount(resource, deletableIDs) + if countErr != nil { + return nil, countErr + } + if sharedCount == 0 { + physicalObjects[identity] = resource + } + } + deletionJobs := resourceDeletionJobsForResources(physicalObjects) + queuedResourceIDs := make(map[string]bool, len(deletionJobs)) + for _, job := range deletionJobs { + queuedResourceIDs[job.ResourceID] = true + } + audits := make([]model.AdminAuditEvent, 0, len(deletable)) + for _, resource := range deletable { + event, eventErr := newAdminAuditEvent(actor, "resource.delete", "resource", resource.ID, "管理员删除存储资源", map[string]any{ + "userId": resource.UserID, "kind": resource.Kind, "provider": normalizedResourceProvider(resource.Provider), + "objectKey": resource.ObjectKey, "physicalDeleteQueued": queuedResourceIDs[resource.ID], + }) + if eventErr != nil { + return nil, eventErr + } + audits = append(audits, *event) + } + if err := s.repo.DeleteAdminResources(deletable, deletionJobs, audits); err != nil { + if errors.Is(err, repository.ErrAdminResourceDeleteChanged) || errors.Is(err, repository.ErrAdminResourceStillReferenced) { + return nil, BadAuthRequest("资源状态或引用已变化,请刷新后重试") + } + return nil, err + } + if len(deletionJobs) > 0 { + go s.drainResourceDeletionJobs(len(deletionJobs)) + } + + result := &AdminResourceDeleteResult{Deleted: []string{}, Blocked: []AdminResourceDeleteBlocked{}} + deletedSet := make(map[string]struct{}, len(deletable)) + for _, resource := range deletable { + deletedSet[resource.ID] = struct{}{} + } + for _, resourceID := range resourceIDs { + if _, deleted := deletedSet[resourceID]; deleted { + result.Deleted = append(result.Deleted, resourceID) + } else if blocked, exists := blockedByID[resourceID]; exists { + result.Blocked = append(result.Blocked, blocked) + } + } + return result, nil +} + +func normalizeAdminResourceDeleteIDs(values []string) ([]string, error) { + if len(values) == 0 { + return nil, BadAuthRequest("请选择要删除的资源") + } + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || len(value) > 80 { + return nil, BadAuthRequest("资源 ID 无效") + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + if len(result) == 0 || len(result) > maxAdminResourceDeleteCount { + return nil, BadAuthRequest("单次最多删除 100 个资源") + } + return result, nil +} + +func adminResourceReferences(snapshot repository.ResourceReferenceSnapshot, resources []model.Resource) map[string][]AdminResourceReferenceView { + result := make(map[string][]AdminResourceReferenceView) + seen := make(map[string]map[string]struct{}) + for _, reference := range snapshot.Direct { + appendAdminResourceReference(result, seen, reference.ResourceID, AdminResourceReferenceView{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + } + for _, resource := range resources { + candidate := map[string]struct{}{resource.ID: {}} + for _, document := range snapshot.Documents { + if documentReferencesResources(document.PrimaryJSON, candidate) || documentReferencesResources(document.SecondaryJSON, candidate) { + appendAdminResourceReference(result, seen, resource.ID, AdminResourceReferenceView{Kind: document.Kind, ID: document.ID, Title: document.Title}) + } + } + } + for resourceID := range result { + sort.Slice(result[resourceID], func(i, j int) bool { + left, right := result[resourceID][i], result[resourceID][j] + if left.Kind != right.Kind { + return left.Kind < right.Kind + } + return left.ID < right.ID + }) + } + return result +} + +func appendAdminResourceReference(result map[string][]AdminResourceReferenceView, seen map[string]map[string]struct{}, resourceID string, reference AdminResourceReferenceView) { + if resourceID == "" { + return + } + if seen[resourceID] == nil { + seen[resourceID] = map[string]struct{}{} + } + key := reference.Kind + "\x00" + reference.ID + if _, exists := seen[resourceID][key]; exists { + return + } + seen[resourceID][key] = struct{}{} + result[resourceID] = append(result[resourceID], reference) +} + +func appendUniqueAdminResourceReference(references []AdminResourceReferenceView, reference AdminResourceReferenceView) []AdminResourceReferenceView { + for _, existing := range references { + if existing.Kind == reference.Kind && existing.ID == reference.ID { + return references + } + } + return append(references, reference) +} + +func supportedResourceDeleteProvider(provider string) bool { + provider = normalizedResourceProvider(provider) + return oneOf(provider, "local", aliyunOSSProvider, tencentCOSProvider, qiniuKodoProvider, s3Provider) +} + +func resourceDeletionJobsForResources(physicalObjects map[string]*model.Resource) []model.ResourceDeletionJob { + resourcesByUser := make(map[string]map[string]*model.Resource) + for identity, resource := range physicalObjects { + if resourcesByUser[resource.UserID] == nil { + resourcesByUser[resource.UserID] = map[string]*model.Resource{} + } + resourcesByUser[resource.UserID][identity] = resource + } + userIDs := make([]string, 0, len(resourcesByUser)) + for userID := range resourcesByUser { + userIDs = append(userIDs, userID) + } + sort.Strings(userIDs) + jobs := []model.ResourceDeletionJob{} + for _, userID := range userIDs { + jobs = append(jobs, resourceDeletionJobs(userID, resourcesByUser[userID])...) + } + return jobs +} diff --git a/backend/internal/service/admin_storage_delete_test.go b/backend/internal/service/admin_storage_delete_test.go new file mode 100644 index 000000000..b66832aaa --- /dev/null +++ b/backend/internal/service/admin_storage_delete_test.go @@ -0,0 +1,170 @@ +package service + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "infinite-canvas/backend/internal/database" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestDeleteAdminResourcesSupportsPartialSuccessAndBatchedReferences(t *testing.T) { + svc, db, dataDir, admin := newAdminStorageDeleteTestService(t) + resources := []model.Resource{ + {ID: "resource-free", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "users/user-1/image/delete-directory"}, + {ID: "resource-blocked", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "users/user-1/image/blocked.png"}, + } + if err := db.Create(&resources).Error; err != nil { + t.Fatal(err) + } + canvas := model.CanvasProject{ID: "canvas-1", UserID: "user-1", Title: "广告分镜", PayloadJSON: `{"nodes":[{"data":{"storageKey":"resource:resource-blocked"}}]}`} + if err := db.Create(&canvas).Error; err != nil { + t.Fatal(err) + } + directoryPath := filepath.Join(dataDir, "resources", filepath.FromSlash(resources[0].ObjectKey)) + if err := os.MkdirAll(directoryPath, 0o750); err != nil { + t.Fatal(err) + } + + result, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{"resource-free", "resource-blocked", "resource-free"}}) + if err != nil { + t.Fatal(err) + } + if len(result.Deleted) != 1 || result.Deleted[0] != "resource-free" { + t.Fatalf("deleted = %#v", result.Deleted) + } + if len(result.Blocked) != 1 || result.Blocked[0].ID != "resource-blocked" || len(result.Blocked[0].References) != 1 || result.Blocked[0].References[0].Kind != "画布" { + t.Fatalf("blocked = %#v", result.Blocked) + } + assertModelCount(t, db, &model.Resource{}, "id = ?", 0, "resource-free") + assertModelCount(t, db, &model.Resource{}, "id = ?", 1, "resource-blocked") + assertModelCount(t, db, &model.ResourceDeletionJob{}, "resource_id = ?", 1, "resource-free") + assertModelCount(t, db, &model.AdminAuditEvent{}, "action = ? AND target_id = ?", 1, "resource.delete", "resource-free") +} + +func TestDeleteAdminResourcesBlocksAnnouncementImage(t *testing.T) { + svc, db, _, admin := newAdminStorageDeleteTestService(t) + resource := model.Resource{ID: "announcement-image", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "announcements/cover.png"} + announcement := model.Announcement{ID: "announcement-1", Title: "维护通知", Content: "content", ImageResourceID: resource.ID, CreatedBy: admin.ID, Status: model.AnnouncementStatusActive} + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&announcement).Error; err != nil { + t.Fatal(err) + } + + result, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{resource.ID}}) + if err != nil { + t.Fatal(err) + } + if len(result.Deleted) != 0 || len(result.Blocked) != 1 || result.Blocked[0].References[0].Kind != "公告" { + t.Fatalf("result = %#v", result) + } + assertModelCount(t, db, &model.Resource{}, "id = ?", 1, resource.ID) +} + +func TestDeleteAdminResourcesKeepsSharedPhysicalObject(t *testing.T) { + svc, db, _, admin := newAdminStorageDeleteTestService(t) + resources := []model.Resource{ + {ID: "resource-delete", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, Provider: "", ObjectKey: "shared/object.png"}, + {ID: "resource-keep", UserID: "user-2", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "shared/object.png"}, + } + if err := db.Create(&resources).Error; err != nil { + t.Fatal(err) + } + + result, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{"resource-delete"}}) + if err != nil { + t.Fatal(err) + } + if len(result.Deleted) != 1 { + t.Fatalf("result = %#v", result) + } + assertModelCount(t, db, &model.Resource{}, "id = ?", 0, "resource-delete") + assertModelCount(t, db, &model.Resource{}, "id = ?", 1, "resource-keep") + assertModelCount(t, db, &model.ResourceDeletionJob{}, "1 = 1", 0) + var audit model.AdminAuditEvent + if err := db.First(&audit, "target_id = ?", "resource-delete").Error; err != nil { + t.Fatal(err) + } + if !strings.Contains(audit.MetadataJSON, `"physicalDeleteQueued":false`) { + t.Fatalf("audit metadata = %s", audit.MetadataJSON) + } +} + +func TestDeleteAdminResourcesRollsBackWhenAuditInsertFails(t *testing.T) { + svc, db, _, admin := newAdminStorageDeleteTestService(t) + resource := model.Resource{ID: "resource-rollback", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local"} + binding := model.ArkPrivateAssetBinding{ID: "binding-1", UserID: admin.ID, ResourceID: resource.ID, ProjectName: "project", Status: "active"} + draft := model.AnnouncementImageDraft{ResourceID: resource.ID, UserID: admin.ID} + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&binding).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&draft).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("CREATE TRIGGER fail_resource_audit BEFORE INSERT ON admin_audit_events WHEN NEW.action = 'resource.delete' BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END;").Error; err != nil { + t.Fatal(err) + } + + if _, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{resource.ID}}); err == nil { + t.Fatal("expected audit failure") + } + assertModelCount(t, db, &model.Resource{}, "id = ?", 1, resource.ID) + assertModelCount(t, db, &model.ArkPrivateAssetBinding{}, "resource_id = ?", 1, resource.ID) + assertModelCount(t, db, &model.AnnouncementImageDraft{}, "resource_id = ?", 1, resource.ID) + assertModelCount(t, db, &model.ResourceDeletionJob{}, "1 = 1", 0) +} + +func TestDeleteAdminResourcesRejectsNonAdminAndOversizedBatch(t *testing.T) { + svc := &Service{} + user := &model.User{ID: "user", Role: model.UserRoleUser, Status: model.UserStatusActive} + if _, err := svc.DeleteAdminResources(user, AdminResourceDeleteRequest{ResourceIDs: []string{"resource-1"}}); err == nil { + t.Fatal("expected non-admin rejection") + } + ids := make([]string, maxAdminResourceDeleteCount+1) + for index := range ids { + ids[index] = newID() + } + if _, err := normalizeAdminResourceDeleteIDs(ids); err == nil { + t.Fatal("expected oversized batch rejection") + } +} + +func newAdminStorageDeleteTestService(t *testing.T) (*Service, *gorm.DB, string, *model.User) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := database.MigrateSchema(db); err != nil { + t.Fatal(err) + } + admin := &model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} + if err := db.Create(admin).Error; err != nil { + t.Fatal(err) + } + dataDir := t.TempDir() + return New(repository.New(db), dataDir), db, dataDir, admin +} + +func assertModelCount(t *testing.T, db *gorm.DB, value any, query string, expected int64, args ...any) { + t.Helper() + var count int64 + dbQuery := db.Model(value).Where(query, args...) + if err := dbQuery.Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != expected { + t.Fatalf("count = %d, want %d", count, expected) + } +} diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index e70ff2251..94e8df90f 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -440,6 +440,19 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [ ] 确认浅色/深色主题下标题、分类选中态、搜索焦点环、键帽和滚动区域清晰可见。 - [ ] 在窄窗口确认分类可横向滚动、快捷键列表改为单列,弹窗不超出视口且长内容仅在列表区滚动。 +## 管理后台存储资源 + +管理后台已新增资源列表、筛选、去重容量统计、预览下载和安全批量删除;以下场景仍需使用真实资源数据确认: + +- [x] 后端专项测试覆盖管理员权限、筛选校验、共享对象去重统计、历史本地资源预览、部分删除成功、公告/画布引用阻止、共享物理对象保留、审计失败事务回滚和 100 项上限。 +- [x] `web` TypeScript 检查通过;只读阶段与删除阶段均复用同一资源页面和统一 API 客户端。 +- [ ] 在 `/admin/resources` 分别按资源 ID、对象路径、用户 ID、类型、状态和存储类型筛选,确认分页与筛选 chip 一致。 +- [ ] 预览并下载本地、阿里云 OSS、腾讯云 COS、七牛云 Kodo 和 S3 的图片、视频、音频与普通文件,确认 Range、登录 Cookie 和错误提示正确。 +- [ ] 选择一个仍被公告、素材、画布、项目、风格、工作流或镜头产物引用的资源,确认删除结果显示真实引用来源且业务数据和物理对象均保留。 +- [ ] 批量选择“可删除、仍被引用、不存在”三类资源,确认只删除安全项,列表和统计刷新,审计事件与 outbox 数量一致。 +- [ ] 创建两条共享同一物理对象的资源记录,只删除其中一条,确认另一条仍能预览且物理对象未清理;两条同时删除时确认只产生一个物理删除任务。 +- [ ] 模拟对象存储暂时失败,确认资源记录事务仍成功、删除任务保留并重试,恢复后物理对象最终清理。 + ## 短剧分镜工作流工作台 短剧工作流已按镜头生产重构为单层项目工作台,并将资产绑定、镜头编辑、生成预览和整集镜头轨道放入同一视口;以下场景仍需用户在真实项目数据与模型渠道下确认: diff --git a/web/src/pages/admin/components/storage-resources-panel.tsx b/web/src/pages/admin/components/storage-resources-panel.tsx index 4ad219351..8b96b31d1 100644 --- a/web/src/pages/admin/components/storage-resources-panel.tsx +++ b/web/src/pages/admin/components/storage-resources-panel.tsx @@ -1,6 +1,6 @@ import { App, Button, Input, Modal, Select } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { Download, Eye, Search } from "lucide-react"; +import { Download, Eye, Search, Trash2 } from "lucide-react"; import { saveAs } from "file-saver"; import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router"; @@ -9,18 +9,19 @@ import { PaginationBar } from "@/components/layout/workspace-page"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { adminResourceFileUrl, + deleteAdminResources, downloadAdminResource, getAdminStorageStats, listAdminResources, type AdminStorageResource, type AdminStorageStats, } from "@/services/api/admin-storage"; -import { AdminDataTable, AdminFilterChip, AdminStatTile, AdminStatusBadge, AdminTableEmpty } from "./admin-ui"; +import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatTile, AdminStatusBadge, AdminTableEmpty } from "./admin-ui"; const pageSizes = [20, 50, 100]; export default function StorageResourcesPanel() { - const { message } = App.useApp(); + const { message, modal } = App.useApp(); const [searchParams, setSearchParams] = useSearchParams(); const keyword = searchParams.get("filter") || ""; const kind = normalizeOption(searchParams.get("kind"), ["image", "video", "audio", "file"]); @@ -37,6 +38,9 @@ export default function StorageResourcesPanel() { const [loading, setLoading] = useState(true); const [previewing, setPreviewing] = useState(null); const [downloadingId, setDownloadingId] = useState(""); + const [selectedIds, setSelectedIds] = useState([]); + const [deleting, setDeleting] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); const requestSequence = useRef(0); const hasFilters = Boolean(keyword || userId || kind !== "all" || status !== "all" || provider !== "all"); @@ -62,7 +66,7 @@ export default function StorageResourcesPanel() { message.error(error instanceof Error ? error.message : "读取存储统计失败"); }); return () => controller.abort(); - }, []); + }, [refreshKey]); useEffect(() => { const sequence = ++requestSequence.current; @@ -89,7 +93,7 @@ export default function StorageResourcesPanel() { }) .finally(() => sequence === requestSequence.current && setLoading(false)); return () => controller.abort(); - }, [debouncedKeyword, debouncedUserId, kind, status, provider, page, pageSize]); + }, [debouncedKeyword, debouncedUserId, kind, status, provider, page, pageSize, refreshKey]); const columns = useMemo>(() => [ { title: "资源", width: 260, render: (_, resource) =>
{fileName(resource.objectKey) || resource.id}
{resource.id}
}, @@ -100,8 +104,8 @@ export default function StorageResourcesPanel() { { title: "大小", dataIndex: "size", width: 110, render: (value) => {formatBytes(value)} }, { title: "规格", width: 135, render: (_, resource) => resourceDimensions(resource) }, { title: "创建时间", dataIndex: "createdAt", width: 170, render: formatTime }, - { title: "操作", width: 154, align: "right", fixed: "right", render: (_, resource) =>
}, - ], [downloadingId]); + { title: "操作", width: 220, align: "right", fixed: "right", render: (_, resource) =>
}, + ], [downloadingId, deleting]); const download = async (resource: AdminStorageResource) => { setDownloadingId(resource.id); @@ -115,6 +119,38 @@ export default function StorageResourcesPanel() { } }; + const confirmDelete = (resourceIds: string[]) => { + const uniqueIds = Array.from(new Set(resourceIds)); + modal.confirm({ + title: uniqueIds.length > 1 ? `删除选中的 ${uniqueIds.length} 个资源?` : "删除这个资源?", + content: "系统会先批量检查公告、素材、画布、项目、工作流和镜头产物引用。仍被引用的资源会保留;无引用资源的记录、清理任务和审计事件会在同一事务提交。", + okText: uniqueIds.length > 1 ? "检查并删除" : "确认删除", + cancelText: "取消", + okButtonProps: { danger: true }, + onOk: async () => { + setDeleting(true); + try { + const result = await deleteAdminResources(uniqueIds); + setSelectedIds([]); + setRefreshKey((value) => value + 1); + if (result.deleted.length > 0) message.success(`已删除 ${result.deleted.length} 个资源`); + if (result.blocked.length > 0) { + modal.warning({ + title: result.deleted.length > 0 ? "部分资源未删除" : "资源未删除", + content: , + okText: "知道了", + }); + } + } catch (error) { + message.error(error instanceof Error ? error.message : "删除资源失败"); + throw error; + } finally { + setDeleting(false); + } + }, + }); + }; + return (
@@ -129,8 +165,9 @@ export default function StorageResourcesPanel() { toolbarActiveFilters={<>{keyword ? updateUrl({ filter: "", page: 1 })} /> : null}{userId ? updateUrl({ userId: "", page: 1 })} /> : null}{kind !== "all" ? updateUrl({ kind: "all", page: 1 })} /> : null}{status !== "all" ? updateUrl({ status: "all", page: 1 })} /> : null}{provider !== "all" ? updateUrl({ provider: "all", page: 1 })} /> : null}} toolbarActive={hasFilters} onReset={() => updateUrl({ filter: "", userId: "", kind: "all", status: "all", provider: "all", page: 1 })} + batchActions={ setSelectedIds([])}>} skeletonColumns={9} - table={{ className: "app-data-table", size: "small", rowKey: "id", loading, columns, dataSource: resources, pagination: false, scroll: { x: 1320 } }} + table={{ className: "app-data-table", size: "small", rowKey: "id", loading, columns, dataSource: resources, rowSelection: { selectedRowKeys: selectedIds, preserveSelectedRowKeys: true, onChange: (keys) => { const next = keys.map(String); if (next.length > 100) message.warning("单次最多选择 100 个资源"); setSelectedIds(next.slice(0, 100)); } }, pagination: false, scroll: { x: 1390 } }} empty={} footer={ updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize })} />} /> @@ -141,6 +178,20 @@ export default function StorageResourcesPanel() { ); } +function DeleteBlockedSummary({ blocked }: { blocked: Array<{ id: string; reason: string; references: Array<{ kind: string; id: string; title: string }> }> }) { + return ( +
+ {blocked.map((item) => ( +
+
{item.id}
+
{item.reason}
+ {item.references.length > 0 ?
{item.references.slice(0, 4).map((reference) => `${reference.kind}「${reference.title || reference.id}」`).join("、")}{item.references.length > 4 ? ` 等 ${item.references.length} 处` : ""}
: null} +
+ ))} +
+ ); +} + function ResourcePreview({ resource }: { resource: AdminStorageResource }) { const url = adminResourceFileUrl(resource.id); if (resource.kind === "image" || resource.mimeType.startsWith("image/")) return {fileName(resource.objectKey); diff --git a/web/src/services/api/admin-storage.ts b/web/src/services/api/admin-storage.ts index 285d35fbd..729e9afc6 100644 --- a/web/src/services/api/admin-storage.ts +++ b/web/src/services/api/admin-storage.ts @@ -54,6 +54,23 @@ export type AdminResourceQuery = { limit?: number; }; +export type AdminResourceReference = { + kind: string; + id: string; + title: string; +}; + +export type AdminResourceDeleteBlocked = { + id: string; + reason: string; + references: AdminResourceReference[]; +}; + +export type AdminResourceDeleteResult = { + deleted: string[]; + blocked: AdminResourceDeleteBlocked[]; +}; + export async function listAdminResources(query: AdminResourceQuery, signal?: AbortSignal) { return request(apiClient.get("/admin/resources", { params: compactApiParams(query), signal })); } @@ -63,6 +80,10 @@ export async function getAdminStorageStats(signal?: AbortSignal) { return result.stats; } +export function deleteAdminResources(resourceIds: string[]) { + return request(apiClient.post("/admin/resources/delete", { resourceIds })); +} + export function adminResourceFileUrl(id: string, download = false) { const base = String(apiBaseURL).replace(/\/+$/, ""); return `${base}/admin/resources/${encodeURIComponent(id)}/file${download ? "?download=1" : ""}`;