fix(*): 删除闭环 - 统一服务端真相并释放画布素材关联
This commit is contained in:
1 parent
6ed1eb5a7c
commit
3658d78881
8 files changed
+135
-60
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string,
|
||||
return resourceStorageKey(resource.id);
|
||||
}
|
||||
|
||||
function mergeById<T extends { id?: string; updatedAt?: string }>(local: T[], remote: T[]) {
|
||||
const items = new Map<string, T>();
|
||||
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<string, string>(items.map((item) => [item.id, item.updatedAt || ""]));
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user