feat(canvas): 画布工作台 - 新增生成副本并修复大型画布性能、素材同步与删除校验
统一图片与视频副本的生成落点并保留参考连接。\n优化大型画布节点虚拟化、媒体懒加载、拖拽与详情进入路径。\n修复素材主版本 ID 同步及删除时的画布独立引用校验。
This commit is contained in:
1 parent
434f63e153
commit
3f4a9ea3d0
45 files changed
+1348
-286
No files matched your search
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user