feat(canvas): 画布工作台 - 新增生成副本并修复大型画布性能、素材同步与删除校验

统一图片与视频副本的生成落点并保留参考连接。\n优化大型画布节点虚拟化、媒体懒加载、拖拽与详情进入路径。\n修复素材主版本 ID 同步及删除时的画布独立引用校验。
This commit is contained in:
ddCat committed 2026-09-01 14:57:05 +08:00
1 parent 434f63e153
commit 3f4a9ea3d0
45 files changed
+1348 -286

No files matched your search

+40 -16
View File
@@ -72,18 +72,40 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er
if snapshotErr != nil {
return snapshotErr
}
sharedAssetResourceIDs := map[string]struct{}{}
for _, reference := range snapshot.Direct {
if _, exists := ownedIDSet[reference.ResourceID]; exists {
if reference.Kind == "素材" {
sharedAssetResourceIDs[reference.ResourceID] = struct{}{}
continue
}
usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title})
}
}
for _, document := range snapshot.Documents {
primaryReferenced := documentReferencesResources(document.PrimaryJSON, ownedIDSet)
secondaryReferenced := documentReferencesResources(document.SecondaryJSON, ownedIDSet)
if primaryReferenced || secondaryReferenced {
referencedIDs := documentReferencedResourceIDs(document.PrimaryJSON, ownedIDSet)
for resourceID := range documentReferencedResourceIDs(document.SecondaryJSON, ownedIDSet) {
referencedIDs[resourceID] = struct{}{}
}
if len(referencedIDs) > 0 {
if document.Kind == "素材" {
for resourceID := range referencedIDs {
sharedAssetResourceIDs[resourceID] = struct{}{}
}
continue
}
usages = append(usages, resourceUsage{Kind: document.Kind, ID: document.ID, Title: document.Title})
}
}
if len(sharedAssetResourceIDs) > 0 {
deletableOwnedIDs := ownedIDs[:0]
for _, resourceID := range ownedIDs {
if _, shared := sharedAssetResourceIDs[resourceID]; !shared {
deletableOwnedIDs = append(deletableOwnedIDs, resourceID)
}
}
ownedIDs = deletableOwnedIDs
}
}
if message := resourceOccupiedMessage(usages); message != "" {
return BadAuthRequest(message)
@@ -233,13 +255,18 @@ func isResourceLocatorField(field string) bool {
}
func documentReferencesResources(raw string, resourceIDs map[string]struct{}) bool {
return len(documentReferencedResourceIDs(raw, resourceIDs)) > 0
}
func documentReferencedResourceIDs(raw string, resourceIDs map[string]struct{}) map[string]struct{} {
matched := map[string]struct{}{}
raw = strings.TrimSpace(raw)
if raw == "" || len(resourceIDs) == 0 {
return false
return matched
}
found := map[string]struct{}{}
var value any
if err := json.Unmarshal([]byte(raw), &value); err == nil {
found := map[string]struct{}{}
if scalar, ok := value.(string); ok {
if resourceID := canvasResourceID(scalar); resourceID != "" {
found[resourceID] = struct{}{}
@@ -247,19 +274,16 @@ func documentReferencesResources(raw string, resourceIDs map[string]struct{}) bo
} else {
walkReferenceDocument(value, "", found)
}
for resourceID := range found {
if _, exists := resourceIDs[resourceID]; exists {
return true
}
} else if resourceID := canvasResourceID(raw); resourceID != "" {
// cover_url 等数据库列可以直接保存一个资源 URL,而不是 JSON。
found[resourceID] = struct{}{}
}
for resourceID := range found {
if _, exists := resourceIDs[resourceID]; exists {
matched[resourceID] = struct{}{}
}
return false
}
// cover_url 等数据库列可以直接保存一个资源 URL,而不是 JSON。
if resourceID := canvasResourceID(raw); resourceID != "" {
_, exists := resourceIDs[resourceID]
return exists
}
return false
return matched
}
func sortedReferenceIDs(values map[string]struct{}) []string {
@@ -164,6 +164,67 @@ func TestDeleteAssetDatabaseFailureLeavesPhysicalObjectAndNoOutbox(t *testing.T)
}
}
func TestDeleteAssetKeepsResourceSharedByIndependentAsset(t *testing.T) {
svc, db, _ := newResourceDeletionTestService(t)
resource := model.Resource{ID: "resource-shared", UserID: "user-1", Provider: "local", ObjectKey: "users/user-1/image/shared.png", Status: model.ResourceStatusReady}
target := model.Asset{ID: "asset-target", UserID: "user-1", Title: "待删除素材", PayloadJSON: `{"data":{"storageKey":"resource:resource-shared"}}`}
independent := model.Asset{ID: "asset-independent", UserID: "user-1", Title: "生成图片", PayloadJSON: `{"data":{"storageKey":"resource:resource-shared"}}`}
for _, item := range []any{&resource, &target, &independent} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
if err := svc.DeleteUserAsset("user-1", target.ID); err != nil {
t.Fatalf("DeleteUserAsset() error = %v", err)
}
var targetCount, independentCount, resourceCount, jobCount int64
if err := db.Model(&model.Asset{}).Where("id = ?", target.ID).Count(&targetCount).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.Asset{}).Where("id = ?", independent.ID).Count(&independentCount).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&resourceCount).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.ResourceDeletionJob{}).Count(&jobCount).Error; err != nil {
t.Fatal(err)
}
if targetCount != 0 || independentCount != 1 || resourceCount != 1 || jobCount != 0 {
t.Fatalf("unexpected delete result: target=%d independent=%d resource=%d jobs=%d", targetCount, independentCount, resourceCount, jobCount)
}
}
func TestDeleteAssetStillRejectsLiveCanvasResourceReference(t *testing.T) {
svc, db, _ := newResourceDeletionTestService(t)
resource := model.Resource{ID: "resource-canvas", UserID: "user-1", Provider: "local", ObjectKey: "users/user-1/image/canvas.png", Status: model.ResourceStatusReady}
asset := model.Asset{ID: "asset-canvas", UserID: "user-1", Title: "画布素材", PayloadJSON: `{"data":{"storageKey":"resource:resource-canvas"}}`}
canvas := model.CanvasProject{ID: "canvas-live", UserID: "user-1", Title: "仍在使用的画布", PayloadJSON: `{"nodes":[{"data":{"storageKey":"resource:resource-canvas"}}]}`}
for _, item := range []any{&resource, &asset, &canvas} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
err := svc.DeleteUserAsset("user-1", asset.ID)
if err == nil || !strings.Contains(err.Error(), "画布「仍在使用的画布」") {
t.Fatalf("DeleteUserAsset() error = %v, want live canvas reference", err)
}
var assetCount, resourceCount int64
if err := db.Model(&model.Asset{}).Where("id = ?", asset.ID).Count(&assetCount).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&resourceCount).Error; err != nil {
t.Fatal(err)
}
if assetCount != 1 || resourceCount != 1 {
t.Fatalf("blocked delete changed data: asset=%d resource=%d", assetCount, resourceCount)
}
}
func TestResourceDeletionWorkerRemovesObjectAndCompletesOutbox(t *testing.T) {
svc, db, dataDir := newResourceDeletionTestService(t)
objectKey := "users/user-1/image/queued.png"