fix(*): 删除闭环 - 统一服务端真相并释放画布素材关联
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user