From ae9b42a452c88be0071b76c32937b7106bf3dabb Mon Sep 17 00:00:00 2001 From: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:45:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(assets):=20=E7=B4=A0=E6=9D=90=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20-=20=E6=8E=92=E9=99=A4=E5=B7=B2=E7=BB=93=E6=9D=9F?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=9A=84=E7=94=9F=E6=88=90=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/resource_delete.go | 11 +++ backend/internal/app/resource_delete_test.go | 74 +++++++++++++++++++ .../internal/repository/resource_reference.go | 15 ++-- docs/content/docs/progress/pending-test.mdx | 7 ++ 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/backend/internal/app/resource_delete.go b/backend/internal/app/resource_delete.go index 9aff809f8..a627ee2f7 100644 --- a/backend/internal/app/resource_delete.go +++ b/backend/internal/app/resource_delete.go @@ -83,6 +83,17 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er } } for _, document := range snapshot.Documents { + // 已结束任务的输出和日志仅记录生成历史,不构成素材占用。 + // 仅在素材删除时放行;孤儿清理仍保留尚未入库的任务产物。 + switch document.TaskStatus { + case model.TaskStatusSucceeded, model.TaskStatusFailed, model.TaskStatusCancelled: + if document.Kind == "任务日志" || document.Kind == "任务结果" { + continue + } + if document.Kind == "任务" { + document.SecondaryJSON = "" + } + } referencedIDs := documentReferencedResourceIDs(document.PrimaryJSON, ownedIDSet) for resourceID := range documentReferencedResourceIDs(document.SecondaryJSON, ownedIDSet) { referencedIDs[resourceID] = struct{}{} diff --git a/backend/internal/app/resource_delete_test.go b/backend/internal/app/resource_delete_test.go index 6c9e024ae..7c541bde1 100644 --- a/backend/internal/app/resource_delete_test.go +++ b/backend/internal/app/resource_delete_test.go @@ -225,6 +225,80 @@ func TestDeleteAssetStillRejectsLiveCanvasResourceReference(t *testing.T) { } } +func TestDeleteGeneratedAssetTaskReferences(t *testing.T) { + for _, tc := range []struct { + name string + status model.TaskStatus + input bool + canvas bool + block bool + }{ + {name: "completed output", status: model.TaskStatusSucceeded}, + {name: "failed output", status: model.TaskStatusFailed}, + {name: "cancelled output", status: model.TaskStatusCancelled}, + {name: "running output", status: model.TaskStatusRunning, block: true}, + {name: "queued output", status: model.TaskStatusQueued, block: true}, + {name: "unknown status", block: true}, + {name: "completed input", status: model.TaskStatusSucceeded, input: true, block: true}, + {name: "completed output on canvas", status: model.TaskStatusSucceeded, canvas: true, block: true}, + } { + t.Run(tc.name, func(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + payload := `{"url":"/api/resources/generated-resource/file"}` + resource := model.Resource{ID: "generated-resource", UserID: "user-1", Provider: "unsupported-test-provider", ObjectKey: "generated.png", Status: model.ResourceStatusReady} + asset := model.Asset{ID: "generated-asset", UserID: "user-1", Title: "生成图片", PayloadJSON: payload} + task := model.Task{ID: "generation-task", UserID: "user-1", Prompt: "生成图片", Status: tc.status, InputJSON: `{}`, ResultJSON: payload} + if tc.input { + task.InputJSON = payload + } + items := []any{&resource, &asset, &task, + &model.TaskLog{ID: "generation-log", UserID: "user-1", TaskID: task.ID, Payload: payload}, + &model.Result{ID: "generation-result", UserID: "user-1", TaskID: task.ID, URL: "/api/resources/generated-resource/file", Payload: payload}, + } + if tc.canvas { + items = append(items, &model.CanvasProject{ID: "canvas", UserID: "user-1", Title: "使用中的画布", PayloadJSON: payload}) + } + for _, item := range items { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + // 自动孤儿清理仍须保留生成历史中的产物,不能扩大为自动删除。 + if err := svc.cleanupDetachedUserResources("user-1", []model.Resource{resource}); err != nil { + t.Fatal(err) + } + remaining, err := svc.repo.ResourcesForUserIDs("user-1", []string{resource.ID}) + if err != nil || len(remaining) != 1 { + t.Fatalf("automatic cleanup must keep task output: resources=%v, error=%v", remaining, err) + } + err = svc.DeleteUserAsset("user-1", asset.ID) + if (err != nil) != tc.block { + t.Fatalf("DeleteUserAsset() = %v, want blocked=%v", err, tc.block) + } + wantRemaining, wantJobs := int64(0), int64(1) + if tc.block { + wantRemaining, wantJobs = 1, 0 + } + for _, check := range []struct { + model any + want int64 + }{ + {&model.Asset{}, wantRemaining}, {&model.Resource{}, wantRemaining}, + {&model.ResourceDeletionJob{}, wantJobs}, + {&model.Task{}, 1}, {&model.TaskLog{}, 1}, {&model.Result{}, 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" diff --git a/backend/internal/repository/resource_reference.go b/backend/internal/repository/resource_reference.go index c6ad79819..02e4cd381 100644 --- a/backend/internal/repository/resource_reference.go +++ b/backend/internal/repository/resource_reference.go @@ -17,6 +17,7 @@ type ResourceReferenceDocument struct { Title string PrimaryJSON string SecondaryJSON string + TaskStatus model.TaskStatus } type ResourceDirectReference struct { @@ -104,11 +105,13 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s } var tasks []model.Task - if err := r.db.Select("id", "prompt", "input_json", "result_json").Where("user_id = ?", userID).Find(&tasks).Error; err != nil { + if err := r.db.Select("id", "prompt", "status", "input_json", "result_json").Where("user_id = ?", userID).Find(&tasks).Error; err != nil { return snapshot, err } + taskStatuses := make(map[string]model.TaskStatus, len(tasks)) for _, task := range tasks { - snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "任务", ID: task.ID, Title: task.Prompt, PrimaryJSON: task.InputJSON, SecondaryJSON: task.ResultJSON}) + taskStatuses[task.ID] = task.Status + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "任务", ID: task.ID, Title: task.Prompt, PrimaryJSON: task.InputJSON, SecondaryJSON: task.ResultJSON, TaskStatus: task.Status}) } var runs []model.CreationRun @@ -127,19 +130,19 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s } var taskLogs []model.TaskLog - if err := r.db.Select("id", "message", "payload").Where("user_id = ?", userID).Find(&taskLogs).Error; err != nil { + if err := r.db.Select("id", "task_id", "message", "payload").Where("user_id = ?", userID).Find(&taskLogs).Error; err != nil { return snapshot, err } for _, taskLog := range taskLogs { - snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "任务日志", ID: taskLog.ID, Title: taskLog.Message, PrimaryJSON: taskLog.Payload}) + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "任务日志", ID: taskLog.ID, Title: taskLog.Message, PrimaryJSON: taskLog.Payload, TaskStatus: taskStatuses[taskLog.TaskID]}) } var results []model.Result - if err := r.db.Select("id", "kind", "url", "payload").Where("user_id = ?", userID).Find(&results).Error; err != nil { + if err := r.db.Select("id", "task_id", "kind", "url", "payload").Where("user_id = ?", userID).Find(&results).Error; err != nil { return snapshot, err } for _, result := range results { - snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "任务结果", ID: result.ID, Title: result.Kind, PrimaryJSON: result.URL, SecondaryJSON: result.Payload}) + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "任务结果", ID: result.ID, Title: result.Kind, PrimaryJSON: result.URL, SecondaryJSON: result.Payload, TaskStatus: taskStatuses[result.TaskID]}) } var projects []model.Project diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 838c91832..1aa3b93d3 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 生成素材删除的任务来源引用 + +- 素材删除不再被已成功、失败或取消任务的输出结果及日志阻止;保留任务历史记录。任务输入、未结束或未知状态任务、画布和其他业务引用仍参与删除校验。 +- 该规则仅用于素材删除,后台孤儿资源清理和管理员资源检查仍保留完整任务引用,避免自动清理尚未入素材库的生成结果。 +- 已补充后端回归用例,覆盖任务状态、输入引用、画布引用、删除事务 Outbox、历史记录保留和自动清理边界;测试包编译通过,本地数据库测试因 `CGO_ENABLED=0` 无法初始化 SQLite,尚未验证通过。 +- 待部署验收:未绑定画布的生成图片可单个及批量彻底删除;被画布或其他任务输入引用时仍提示来源。删除后任务历史中的原文件不再可用,共享素材仍保留其资源。 + ## 欢迎页开放开关 - 管理员在 `/admin/settings/appearance` 的英文品牌标识下方切换欢迎页,立即保存,沿用功能配置保存及审计流程,默认开启;功能开放页不再编辑该字段。