diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 77985e508..b7c854a61 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -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) { diff --git a/backend/internal/repository/resource_reference.go b/backend/internal/repository/resource_reference.go index 36767dcb6..1c596860b 100644 --- a/backend/internal/repository/resource_reference.go +++ b/backend/internal/repository/resource_reference.go @@ -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 }) } diff --git a/backend/internal/service/project_delete_test.go b/backend/internal/service/project_delete_test.go index ae00aa753..f2a4eb16a 100644 --- a/backend/internal/service/project_delete_test.go +++ b/backend/internal/service/project_delete_test.go @@ -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} diff --git a/backend/internal/service/resource_delete.go b/backend/internal/service/resource_delete.go index c4ce25def..528f2320f 100644 --- a/backend/internal/service/resource_delete.go +++ b/backend/internal/service/resource_delete.go @@ -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 diff --git a/backend/internal/service/resource_delete_test.go b/backend/internal/service/resource_delete_test.go index 7f223384f..7b08ed896 100644 --- a/backend/internal/service/resource_delete_test.go +++ b/backend/internal/service/resource_delete_test.go @@ -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) } diff --git a/backend/internal/service/user_data.go b/backend/internal/service/user_data.go index 8c2ca7008..177c18a61 100644 --- a/backend/internal/service/user_data.go +++ b/backend/internal/service/user_data.go @@ -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) } diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index 9086d888c..926530907 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -3,7 +3,7 @@ import { getImageBlob } from "@/services/image-storage"; import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteUserDataSnapshot, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data"; import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources"; import type { Asset } from "@/stores/use-asset-store"; -import { useAssetStore } from "@/stores/use-asset-store"; +import { flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store"; import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store"; @@ -34,13 +34,10 @@ export async function syncRemoteUserData(userId?: string | null) { const snapshot = await getRemoteUserDataSnapshot(); remoteProjectVersions = versionMap(snapshot.projects); remoteAssetVersions = versionMap(snapshot.assets); - const localProjects = useCanvasStore.getState().projects; - const localAssets = useAssetStore.getState().assets; - const mergedProjects = mergeById(localProjects, snapshot.projects); - // 这里只合并结构化素材数据,不在登录阶段解析图片/视频/音频 URL;媒体由实际使用方按需解析。 - const mergedAssets = mergeById(localAssets, snapshot.assets); - useCanvasStore.getState().replaceProjects(mergedProjects); - useAssetStore.getState().replaceAssets(mergedAssets); + // 登录时服务端是实体真相。浏览器 IndexedDB 只作为首屏缓存,不能把服务端已删除的记录补回去。 + // 这里只替换结构化记录,不在登录阶段解析图片/视频/音频 URL;媒体由实际使用方按需解析。 + useCanvasStore.getState().replaceProjects(snapshot.projects); + useAssetStore.getState().replaceAssets(snapshot.assets); } finally { applyingRemoteState = false; } @@ -174,6 +171,7 @@ export async function deleteAssetWithRemoteSync(id: string) { remoteAssetVersions.delete(id); } useAssetStore.getState().removeAsset(id); + await flushAssetStorePersistence(); }); } @@ -336,19 +334,6 @@ async function uploadLocalStorageKey(storageKey: string, payload: Record(local: T[], remote: T[]) { - const items = new Map(); - remote.forEach((item) => { - if (item.id) items.set(item.id, item); - }); - local.forEach((item) => { - if (!item.id) return; - const current = items.get(item.id); - if (!current || timeValue(item.updatedAt) >= timeValue(current.updatedAt)) items.set(item.id, item); - }); - return Array.from(items.values()).sort((a, b) => timeValue(b.updatedAt) - timeValue(a.updatedAt)); -} - function versionMap(items: Array<{ id: string; updatedAt?: string }>) { return new Map(items.map((item) => [item.id, item.updatedAt || ""])); } diff --git a/web/test/user-scoped-generation-persistence.test.ts b/web/test/user-scoped-generation-persistence.test.ts index a987887d2..908f2e21b 100644 --- a/web/test/user-scoped-generation-persistence.test.ts +++ b/web/test/user-scoped-generation-persistence.test.ts @@ -4107,3 +4107,45 @@ test("canvas deletion removes the remote project before updating local state", a else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); } }); + +test("login replaces stale local entities instead of resurrecting remote deletions", async () => { + const originalWindow = (globalThis as { window?: unknown }).window; + const originalGetItem = localforage.getItem.bind(localforage); + const originalSetItem = localforage.setItem.bind(localforage); + const previousAdapter = apiClient.defaults.adapter; + const previousProjects = useCanvasStore.getState().projects; + const requestUrls: string[] = []; + const staleProject = storedCanvasProject("canvas-stale-after-delete", "服务端已删除"); + + apiClient.defaults.adapter = async (config) => { + const url = String(config.url || ""); + requestUrls.push(`${String(config.method || "get").toLowerCase()} ${url}`); + const data = url.includes("user-data/snapshot") ? { projects: [], assets: [] } : { projects: [] }; + return { data: { code: 0, data, msg: "" }, status: 200, statusText: "OK", headers: {}, config }; + }; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + setTimeout: () => 1, + clearTimeout: () => undefined, + localStorage: { getItem: () => null, setItem: () => undefined, removeItem: () => undefined }, + }, + }); + localforage.getItem = (async () => null) as typeof localforage.getItem; + localforage.setItem = (async (_key: string, value: string) => value) as typeof localforage.setItem; + try { + resetRemoteUserDataSync(); + useCanvasStore.setState({ projects: [staleProject] }); + await syncRemoteUserData("account-A"); + expect(useCanvasStore.getState().projects).toEqual([]); + expect(requestUrls.some((url) => url.startsWith("put ") || url.startsWith("post "))).toBe(false); + } finally { + resetRemoteUserDataSync(); + useCanvasStore.setState({ projects: previousProjects }); + localforage.getItem = originalGetItem; + localforage.setItem = originalSetItem; + apiClient.defaults.adapter = previousAdapter; + if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window; + else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +});