fix(*): 删除闭环 - 统一服务端真相并释放画布素材关联

This commit is contained in:
ddCat
2026-08-25 15:22:05 +08:00
parent 9d848b9daa
commit d2748243d8
8 changed files with 135 additions and 60 deletions

View File

@@ -999,7 +999,22 @@ func (r *Repository) UpsertCanvasProject(project *model.CanvasProject) error {
}
func (r *Repository) DeleteCanvasProject(userID string, id string) error {
return r.db.Delete(&model.CanvasProject{}, "id = ? AND user_id = ?", id, userID).Error
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ? AND project_id = ?", userID, id).Delete(&model.CanvasShare{}).Error; err != nil {
return err
}
if err := tx.Where("canvas_id = ?", id).Delete(&model.CanvasUnitLink{}).Error; err != nil {
return err
}
// 任务和会话是审计记录,不随独立画布实体保留归属 ID,避免删除后继续挂住画布上下文。
if err := tx.Model(&model.Task{}).Where("user_id = ? AND project_id = ?", userID, id).Update("project_id", "").Error; err != nil {
return err
}
if err := tx.Model(&model.Session{}).Where("user_id = ? AND project_id = ?", userID, id).Update("project_id", "").Error; err != nil {
return err
}
return tx.Delete(&model.CanvasProject{}, "id = ? AND user_id = ?", id, userID).Error
})
}
func (r *Repository) Projects(userID string) ([]model.Project, error) {

View File

@@ -102,14 +102,6 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s
snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "画布", ID: canvas.ID, Title: canvas.Title, PrimaryJSON: canvas.PayloadJSON})
}
var tasks []model.Task
if err := resourceTextQuery(r.db.Where("user_id = ?", userID), []string{"input_json", "result_json"}, resourceIDs).Find(&tasks).Error; err != nil {
return snapshot, err
}
for _, task := range tasks {
snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "任务", ID: task.ID, Title: task.Prompt, PrimaryJSON: task.InputJSON, SecondaryJSON: task.ResultJSON})
}
var projects []model.Project
if err := resourceTextQuery(r.db.Where("user_id = ?", userID), []string{"style_profile_json"}, resourceIDs).Find(&projects).Error; err != nil {
return snapshot, err
@@ -238,12 +230,21 @@ func (r *Repository) AssetBusinessReferences(userID string, assetID string) ([]R
func (r *Repository) DeleteAssetAndResources(userID string, assetID string, resourceIDs []string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id = ?", assetID)
if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.ShotAssetReference{}).Error; err != nil {
return err
}
if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.CharacterVoiceBinding{}).Error; err != nil {
return err
}
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 {
return err
}
if err := tx.Where("resolved_asset_id = ?", assetID).Delete(&model.ProjectAssetCandidate{}).Error; err != nil {
return err
}
if err := tx.Where("asset_id = ?", assetID).Delete(&model.AssetVersion{}).Error; err != nil {
return err
}
@@ -253,6 +254,9 @@ func (r *Repository) DeleteAssetAndResources(userID string, assetID string, reso
if len(resourceIDs) == 0 {
return nil
}
if err := tx.Where("resource_id IN ?", resourceIDs).Delete(&model.ArkPrivateAssetBinding{}).Error; err != nil {
return err
}
return tx.Where("user_id = ? AND id IN ?", userID, resourceIDs).Delete(&model.Resource{}).Error
})
}

View File

@@ -149,6 +149,55 @@ func TestDeleteProjectUnlinksCanvasAndKeepsIndependentRecords(t *testing.T) {
}
}
func TestDeleteUserCanvasProjectDetachesTaskAndSessionScope(t *testing.T) {
service, db := newProjectDeleteTestService(t)
canvas := model.CanvasProject{ID: "canvas-delete-1", UserID: "user-1", Title: "待删除画布", PayloadJSON: `{"id":"canvas-delete-1"}`}
task := model.Task{ID: "task-canvas-delete-1", UserID: "user-1", ProjectID: canvas.ID, Status: model.TaskStatusSucceeded, Prompt: "已完成"}
session := model.Session{ID: "session-canvas-delete-1", UserID: "user-1", ProjectID: canvas.ID, Status: model.SessionStatusCompleted}
canvasLink := model.CanvasUnitLink{ID: "canvas-link-delete-1", ProjectID: "project-1", CanvasID: canvas.ID, UnitID: "unit-1", Role: "primary"}
for _, item := range []any{&canvas, &model.CanvasShare{ID: "share-canvas-delete-1", UserID: "user-1", ProjectID: canvas.ID}, &canvasLink, &task, &session} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
if err := service.DeleteUserCanvasProject("user-1", canvas.ID); err != nil {
t.Fatal(err)
}
var storedCanvas model.CanvasProject
if err := db.First(&storedCanvas, "id = ?", canvas.ID).Error; !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("canvas error = %v, want record not found", err)
}
var storedTask model.Task
if err := db.First(&storedTask, "id = ?", task.ID).Error; err != nil {
t.Fatal(err)
}
if storedTask.ProjectID != "" {
t.Fatalf("task project id = %q, want empty", storedTask.ProjectID)
}
var storedSession model.Session
if err := db.First(&storedSession, "id = ?", session.ID).Error; err != nil {
t.Fatal(err)
}
if storedSession.ProjectID != "" {
t.Fatalf("session project id = %q, want empty", storedSession.ProjectID)
}
var shareCount int64
if err := db.Model(&model.CanvasShare{}).Where("project_id = ?", canvas.ID).Count(&shareCount).Error; err != nil {
t.Fatal(err)
}
if shareCount != 0 {
t.Fatalf("canvas share count = %d, want 0", shareCount)
}
var linkCount int64
if err := db.Model(&model.CanvasUnitLink{}).Where("canvas_id = ?", canvas.ID).Count(&linkCount).Error; err != nil {
t.Fatal(err)
}
if linkCount != 0 {
t.Fatalf("canvas link count = %d, want 0", linkCount)
}
}
func TestDeleteProjectRejectsActiveProjectOrCanvasTasks(t *testing.T) {
service, db := newProjectDeleteTestService(t)
project := model.Project{ID: "project-1", UserID: "user-1", Name: "短剧", Status: model.ProjectStatusActive}

View File

@@ -34,12 +34,11 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er
}
resourceIDs := map[string]struct{}{}
originTaskIDs := map[string]struct{}{}
if err := collectOwnedAssetDocumentReferences(asset.PayloadJSON, resourceIDs, originTaskIDs); err != nil {
if err := collectOwnedAssetDocumentReferences(asset.PayloadJSON, resourceIDs); err != nil {
return BadAuthRequest("素材数据无法解析,已停止删除以避免误删文件")
}
for _, version := range versions {
if err := collectOwnedAssetDocumentReferences(version.DefinitionJSON, resourceIDs, originTaskIDs); err != nil {
if err := collectOwnedAssetDocumentReferences(version.DefinitionJSON, resourceIDs); err != nil {
return BadAuthRequest("素材版本数据无法解析,已停止删除以避免误删文件")
}
}
@@ -47,10 +46,7 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er
if resourceID := validCanvasResourceID(representation.ResourceID); resourceID != "" {
resourceIDs[resourceID] = struct{}{}
}
if taskID := validCanvasResourceID(representation.TaskID); taskID != "" {
originTaskIDs[taskID] = struct{}{}
}
if err := collectOwnedAssetDocumentReferences(representation.MetadataJSON, resourceIDs, originTaskIDs); err != nil {
if err := collectOwnedAssetDocumentReferences(representation.MetadataJSON, resourceIDs); err != nil {
return BadAuthRequest("素材表现数据无法解析,已停止删除以避免误删文件")
}
}
@@ -84,10 +80,6 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er
for _, document := range snapshot.Documents {
primaryReferenced := documentReferencesResources(document.PrimaryJSON, ownedIDSet)
secondaryReferenced := documentReferencesResources(document.SecondaryJSON, ownedIDSet)
if document.Kind == "任务" {
_, originTask := originTaskIDs[document.ID]
secondaryReferenced = secondaryReferenced && !originTask
}
if primaryReferenced || secondaryReferenced {
usages = append(usages, resourceUsage{Kind: document.Kind, ID: document.ID, Title: document.Title})
}
@@ -160,7 +152,7 @@ func resourceOccupiedMessage(usages []resourceUsage) string {
return "素材仍被" + strings.Join(visible, "、") + "引用,请先在对应画布、任务或业务记录中解除引用后再删除"
}
func collectOwnedAssetDocumentReferences(raw string, resourceIDs map[string]struct{}, taskIDs map[string]struct{}) error {
func collectOwnedAssetDocumentReferences(raw string, resourceIDs map[string]struct{}) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
@@ -169,19 +161,19 @@ func collectOwnedAssetDocumentReferences(raw string, resourceIDs map[string]stru
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return err
}
walkReferenceDocument(value, "", resourceIDs, taskIDs)
walkReferenceDocument(value, "", resourceIDs)
return nil
}
func walkReferenceDocument(value any, parentKey string, resourceIDs map[string]struct{}, taskIDs map[string]struct{}) {
func walkReferenceDocument(value any, parentKey string, resourceIDs map[string]struct{}) {
switch item := value.(type) {
case map[string]any:
for key, child := range item {
walkReferenceDocument(child, key, resourceIDs, taskIDs)
walkReferenceDocument(child, key, resourceIDs)
}
case []any:
for _, child := range item {
walkReferenceDocument(child, parentKey, resourceIDs, taskIDs)
walkReferenceDocument(child, parentKey, resourceIDs)
}
case string:
key := normalizeReferenceKey(parentKey)
@@ -195,11 +187,6 @@ func walkReferenceDocument(value any, parentKey string, resourceIDs map[string]s
resourceIDs[resourceID] = struct{}{}
}
}
if key == "taskid" || key == "taskids" {
if taskID := validCanvasResourceID(item); taskID != "" {
taskIDs[taskID] = struct{}{}
}
}
}
}
@@ -232,7 +219,7 @@ func documentReferencesResources(raw string, resourceIDs map[string]struct{}) bo
var value any
if err := json.Unmarshal([]byte(raw), &value); err == nil {
found := map[string]struct{}{}
walkReferenceDocument(value, "", found, map[string]struct{}{})
walkReferenceDocument(value, "", found)
for resourceID := range found {
if _, exists := resourceIDs[resourceID]; exists {
return true

View File

@@ -9,12 +9,11 @@ import (
func TestCollectOwnedAssetDocumentReferences(t *testing.T) {
resources := map[string]struct{}{}
tasks := map[string]struct{}{}
raw := `{
"data":{"storageKey":"resource:resource-1","url":"/api/resources/resource-2/file"},
"metadata":{"taskId":"task-1","referenceResourceIds":["resource-3"],"errorDetails":"failed near /api/resources/not-a-reference/file"}
}`
if err := collectOwnedAssetDocumentReferences(raw, resources, tasks); err != nil {
if err := collectOwnedAssetDocumentReferences(raw, resources); err != nil {
t.Fatal(err)
}
for _, resourceID := range []string{"resource-1", "resource-2", "resource-3"} {
@@ -22,9 +21,6 @@ func TestCollectOwnedAssetDocumentReferences(t *testing.T) {
t.Fatalf("resource %q was not collected: %#v", resourceID, resources)
}
}
if _, exists := tasks["task-1"]; !exists {
t.Fatalf("origin task was not collected: %#v", tasks)
}
if _, exists := resources["not-a-reference"]; exists {
t.Fatalf("diagnostic text must not be treated as an actual resource reference: %#v", resources)
}

View File

@@ -225,9 +225,6 @@ func (s *Service) UpsertUserCanvasProject(userID string, raw json.RawMessage) (U
}
func (s *Service) DeleteUserCanvasProject(userID string, id string) error {
if err := s.repo.DeleteCanvasShare(userID, id); err != nil {
return err
}
return s.repo.DeleteCanvasProject(userID, id)
}