From 16913ee72cd4f8c14b7eccec0051b10ac5d36f5d Mon Sep 17 00:00:00 2001 From: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:18:49 +0800 Subject: [PATCH] fix(canvas): keep uploaded resources in asset library (#388) --- backend/internal/repository/repository.go | 34 ++- .../internal/repository/resource_cleanup.go | 101 +++++++++ .../internal/repository/resource_reference.go | 56 +++++ .../internal/service/canvas_asset_guard.go | 201 ++++++++++++++++++ .../internal/service/resource_delete_test.go | 92 ++++++++ .../service/resource_deletion_worker.go | 92 ++++++++ backend/internal/service/upload_quota_test.go | 11 +- backend/internal/service/user_data.go | 16 ++ backend/internal/service/user_data_test.go | 102 +++++++++ .../content/docs/backend/backend-database.mdx | 6 +- docs/content/docs/progress/pending-test.mdx | 7 + .../director/canvas-director-workbench.tsx | 2 + web/src/pages/canvas/index.tsx | 68 +++++- web/src/pages/canvas/project.tsx | 11 +- web/src/pages/canvas/use-canvas-director.ts | 17 +- .../pages/canvas/use-canvas-media-tools.ts | 61 ++++-- web/src/pages/canvas/use-canvas-upload.ts | 39 +++- web/src/services/canvas-asset-repair.ts | 118 ++++++++++ web/src/services/project-asset-sync.ts | 7 +- web/src/services/user-data-sync.ts | 23 +- web/src/types/timeline.ts | 2 + ...user-scoped-generation-persistence.test.ts | 122 +++++++++++ 22 files changed, 1154 insertions(+), 34 deletions(-) create mode 100644 backend/internal/repository/resource_cleanup.go create mode 100644 backend/internal/service/canvas_asset_guard.go create mode 100644 web/src/services/canvas-asset-repair.ts diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index e8b03cc6d..8db8bc1b1 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -989,9 +989,17 @@ func (r *Repository) UserStoredFileBytes(userID string) (int64, error) { var total int64 err := r.db.Raw(` SELECT - (SELECT COALESCE(SUM(size), 0) FROM resources WHERE user_id = ?) + COALESCE(( + SELECT SUM(physical_resources.size) + FROM ( + SELECT MAX(size) AS size + FROM resources + WHERE user_id = ? AND status = ? + GROUP BY COALESCE(NULLIF(provider, ''), 'local'), endpoint, bucket, object_key + ) AS physical_resources + ), 0) + (SELECT COALESCE(SUM(size), 0) FROM session_files WHERE user_id = ?) - `, userID, userID).Scan(&total).Error + `, userID, model.ResourceStatusReady, userID).Scan(&total).Error return total, err } @@ -1053,6 +1061,19 @@ func (r *Repository) Resources(userID string, limit int) ([]model.Resource, erro return resources, err } +func (r *Repository) ResourceCleanupCandidates(incompleteBefore time.Time, readyBefore time.Time, limit int) ([]model.Resource, error) { + var resources []model.Resource + if limit <= 0 || limit > 500 { + limit = 100 + } + err := r.db.Where( + "(status IN ? AND updated_at <= ?) OR (status = ? AND created_at <= ?)", + []model.ResourceStatus{model.ResourceStatusPending, model.ResourceStatusFailed}, incompleteBefore, + model.ResourceStatusReady, readyBefore, + ).Order("created_at asc, id asc").Limit(limit).Find(&resources).Error + return resources, err +} + func (r *Repository) Assets(userID string) ([]model.Asset, error) { var assets []model.Asset err := r.db.Order("updated_at desc").Find(&assets, "user_id = ?", userID).Error @@ -1073,6 +1094,15 @@ func (r *Repository) AssetForUser(userID string, id string) (*model.Asset, error return &asset, nil } +func (r *Repository) AssetsForUserIDs(userID string, ids []string) ([]model.Asset, error) { + if len(ids) == 0 { + return nil, nil + } + var assets []model.Asset + err := r.db.Find(&assets, "user_id = ? AND id IN ?", userID, ids).Error + return assets, err +} + func (r *Repository) UpsertAsset(asset *model.Asset) error { result := r.db.Model(&model.Asset{}). Where("id = ? AND user_id = ?", asset.ID, asset.UserID). diff --git a/backend/internal/repository/resource_cleanup.go b/backend/internal/repository/resource_cleanup.go new file mode 100644 index 000000000..925652dac --- /dev/null +++ b/backend/internal/repository/resource_cleanup.go @@ -0,0 +1,101 @@ +package repository + +import ( + "errors" + "strings" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ErrResourceCleanupSetChanged = errors.New("resource cleanup set changed") +var ErrResourceCleanupStillReferenced = errors.New("resource cleanup resource is still directly referenced") + +// DeleteDetachedResources removes Resource rows and enqueues physical deletion in one transaction. +// JSON references are checked by the service before this call; direct foreign-key-like references +// are checked again while the candidate rows are locked to avoid deleting a newly attached object. +func (r *Repository) DeleteDetachedResources(resources []model.Resource, deletionJobs []model.ResourceDeletionJob) error { + if len(resources) == 0 { + return nil + } + resourceIDs := make([]string, 0, len(resources)) + for _, resource := range resources { + resourceIDs = append(resourceIDs, resource.ID) + } + return r.db.Transaction(func(tx *gorm.DB) error { + var current []model.Resource + query := tx.Where("id IN ?", resourceIDs) + if r.Dialect() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + if err := query.Find(¤t).Error; err != nil { + return err + } + if len(current) != len(resources) { + return ErrResourceCleanupSetChanged + } + // The service performs exact JSON parsing before entering this transaction. + // This conservative second check closes the common race where an Asset or + // canvas is attached after that snapshot but before Resource deletion. + documentsByUser := map[string][]string{} + for _, resource := range current { + if _, loaded := documentsByUser[resource.UserID]; loaded { + continue + } + var assetDocuments []string + if err := tx.Model(&model.Asset{}).Where("user_id = ?", resource.UserID).Pluck("payload_json", &assetDocuments).Error; err != nil { + return err + } + var canvasDocuments []string + if err := tx.Model(&model.CanvasProject{}).Where("user_id = ?", resource.UserID).Pluck("payload_json", &canvasDocuments).Error; err != nil { + return err + } + documentsByUser[resource.UserID] = append(assetDocuments, canvasDocuments...) + } + for _, resource := range current { + storageKey := "resource:" + resource.ID + `"` + fileURL := "/api/resources/" + resource.ID + "/" + for _, document := range documentsByUser[resource.UserID] { + if strings.Contains(document, storageKey) || strings.Contains(document, fileURL) { + return ErrResourceCleanupStillReferenced + } + } + } + for _, check := range []struct { + model any + query string + }{ + {&model.Announcement{}, "image_resource_id IN ?"}, + {&model.AnnouncementImageDraft{}, "resource_id IN ?"}, + {&model.AssetRepresentation{}, "resource_id IN ?"}, + {&model.VoiceProfile{}, "sample_resource_id IN ?"}, + {&model.ShotArtifact{}, "resource_id IN ?"}, + } { + var count int64 + if err := tx.Model(check.model).Where(check.query, resourceIDs).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return ErrResourceCleanupStillReferenced + } + } + if err := tx.Where("resource_id IN ?", resourceIDs).Delete(&model.ArkPrivateAssetBinding{}).Error; err != nil { + return err + } + if len(deletionJobs) > 0 { + if err := tx.Create(&deletionJobs).Error; err != nil { + return err + } + } + deleted := tx.Where("id IN ?", resourceIDs).Delete(&model.Resource{}) + if deleted.Error != nil { + return deleted.Error + } + if deleted.RowsAffected != int64(len(resourceIDs)) { + return ErrResourceCleanupSetChanged + } + return nil + }) +} diff --git a/backend/internal/repository/resource_reference.go b/backend/internal/repository/resource_reference.go index 4b2563e85..72966a342 100644 --- a/backend/internal/repository/resource_reference.go +++ b/backend/internal/repository/resource_reference.go @@ -103,6 +103,46 @@ 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 := r.db.Select("id", "prompt", "input_json", "result_json").Where("user_id = ?", userID).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 sessions []model.Session + if err := r.db.Select("id", "prompt", "canvas_snapshot_json", "canvas_ops_json").Where("user_id = ?", userID).Find(&sessions).Error; err != nil { + return snapshot, err + } + for _, session := range sessions { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "会话", ID: session.ID, Title: session.Prompt, PrimaryJSON: session.CanvasSnapshotJSON, SecondaryJSON: session.CanvasOpsJSON}) + } + + var messages []model.Message + if err := r.db.Select("id", "content", "payload").Where("user_id = ?", userID).Find(&messages).Error; err != nil { + return snapshot, err + } + for _, message := range messages { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "会话消息", ID: message.ID, Title: message.Content, PrimaryJSON: message.Payload}) + } + + var taskLogs []model.TaskLog + if err := r.db.Select("id", "message", "payload").Where("user_id = ?", userID).Find(&taskLogs).Error; err != nil { + return snapshot, err + } + for _, taskLog := range taskLogs { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "任务日志", ID: taskLog.ID, Title: taskLog.Message, PrimaryJSON: taskLog.Payload}) + } + + var results []model.Result + if err := r.db.Select("id", "kind", "url", "payload").Where("user_id = ?", userID).Find(&results).Error; err != nil { + return snapshot, err + } + for _, result := range results { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "任务结果", ID: result.ID, Title: result.Kind, PrimaryJSON: result.URL, SecondaryJSON: result.Payload}) + } + var projects []model.Project if err := r.db.Where("user_id = ?", userID).Find(&projects).Error; err != nil { return snapshot, err @@ -221,6 +261,22 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s for _, artifact := range artifacts { snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "镜头产物", ID: artifact.ID, Title: artifact.Title, ResourceID: artifact.ResourceID}) } + + var announcements []model.Announcement + if err := r.db.Where("created_by = ? AND image_resource_id IN ?", userID, resourceIDs).Find(&announcements).Error; err != nil { + return snapshot, err + } + for _, announcement := range announcements { + snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "公告", ID: announcement.ID, Title: announcement.Title, ResourceID: announcement.ImageResourceID}) + } + + var announcementDrafts []model.AnnouncementImageDraft + if err := r.db.Where("user_id = ? AND resource_id IN ?", userID, resourceIDs).Find(&announcementDrafts).Error; err != nil { + return snapshot, err + } + for _, draft := range announcementDrafts { + snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "公告草稿", ID: draft.ResourceID, ResourceID: draft.ResourceID}) + } return snapshot, nil } diff --git a/backend/internal/service/canvas_asset_guard.go b/backend/internal/service/canvas_asset_guard.go new file mode 100644 index 000000000..6e4b2a50d --- /dev/null +++ b/backend/internal/service/canvas_asset_guard.go @@ -0,0 +1,201 @@ +package service + +import ( + "encoding/json" + "strings" + + "infinite-canvas/backend/internal/model" +) + +type canvasMediaAssetReference struct { + AssetID string + ResourceID string +} + +// validateCanvasMediaAssets is the final server-side invariant for canvas sync: +// a persisted canvas may only point at an uploaded Resource through an Asset +// owned by the same user. The client writes Assets before canvases, so rejecting +// an incomplete pair prevents a durable Resource-only (ghost capacity) state. +func (s *Service) validateCanvasMediaAssets(userID string, raw json.RawMessage) error { + references, err := canvasMediaAssetReferences(raw) + if err != nil { + return BadAuthRequest("画布媒体数据格式错误") + } + if len(references) == 0 { + return nil + } + + assetIDSet := make(map[string]struct{}, len(references)) + resourceIDSet := make(map[string]struct{}, len(references)) + for _, reference := range references { + if reference.AssetID == "" { + return BadAuthRequest("画布媒体尚未进入素材库,请等待同步完成后重试") + } + assetIDSet[reference.AssetID] = struct{}{} + resourceIDSet[reference.ResourceID] = struct{}{} + } + + assets, err := s.repo.AssetsForUserIDs(userID, sortedReferenceIDs(assetIDSet)) + if err != nil { + return err + } + assetResources := make(map[string]map[string]struct{}, len(assets)) + for _, asset := range assets { + assetResources[asset.ID] = documentReferencedResourceIDs(asset.PayloadJSON, resourceIDSet) + } + + resources, err := s.repo.ResourcesForUserIDs(userID, sortedReferenceIDs(resourceIDSet)) + if err != nil { + return err + } + readyResources := make(map[string]struct{}, len(resources)) + for _, resource := range resources { + if resource.Status == model.ResourceStatusReady { + readyResources[resource.ID] = struct{}{} + } + } + + for _, reference := range references { + if _, exists := readyResources[reference.ResourceID]; !exists { + return BadAuthRequest("画布媒体对应的云端资源不存在或尚未就绪,请重新上传") + } + resourceIDs, assetExists := assetResources[reference.AssetID] + if !assetExists { + return BadAuthRequest("画布媒体尚未进入素材库,请等待同步完成后重试") + } + if _, matches := resourceIDs[reference.ResourceID]; !matches { + return BadAuthRequest("画布媒体与素材库记录不一致,请重新同步") + } + } + return nil +} + +// validateAssetCanvasReferences prevents an Asset update from changing the +// resource behind a canvas that already points at that Asset. +func (s *Service) validateAssetCanvasReferences(userID string, asset model.Asset) error { + canvases, err := s.repo.CanvasProjects(userID) + if err != nil { + return err + } + for _, canvas := range canvases { + references, parseErr := canvasMediaAssetReferences(json.RawMessage(canvas.PayloadJSON)) + if parseErr != nil { + return BadAuthRequest("已有画布媒体数据无法解析,已停止修改素材") + } + for _, reference := range references { + if reference.AssetID != asset.ID { + continue + } + candidate := map[string]struct{}{reference.ResourceID: {}} + if !documentReferencesResources(asset.PayloadJSON, candidate) { + return BadAuthRequest("素材仍被画布引用,不能替换为其他云端资源") + } + } + } + return nil +} + +// validateAssetReplacementCanvasReferences applies the same invariant to the +// legacy full-replacement endpoint, which otherwise could silently remove an +// Asset that a canvas still needs. +func (s *Service) validateAssetReplacementCanvasReferences(userID string, assets []model.Asset) error { + assetByID := make(map[string]model.Asset, len(assets)) + for _, asset := range assets { + assetByID[asset.ID] = asset + } + canvases, err := s.repo.CanvasProjects(userID) + if err != nil { + return err + } + for _, canvas := range canvases { + references, parseErr := canvasMediaAssetReferences(json.RawMessage(canvas.PayloadJSON)) + if parseErr != nil { + return BadAuthRequest("已有画布媒体数据无法解析,已停止替换素材库") + } + for _, reference := range references { + asset, exists := assetByID[reference.AssetID] + if !exists { + return BadAuthRequest("素材仍被画布引用,不能从素材库移除") + } + candidate := map[string]struct{}{reference.ResourceID: {}} + if !documentReferencesResources(asset.PayloadJSON, candidate) { + return BadAuthRequest("画布媒体与替换后的素材库记录不一致") + } + } + } + return nil +} + +func canvasMediaAssetReferences(raw json.RawMessage) ([]canvasMediaAssetReference, error) { + var payload struct { + Nodes []struct { + Type string `json:"type"` + Metadata struct { + AssetID string `json:"assetId"` + StorageKey string `json:"storageKey"` + Content string `json:"content"` + } `json:"metadata"` + } `json:"nodes"` + Timeline struct { + Clips []struct { + DirectMedia *struct { + Kind string `json:"kind"` + AssetID string `json:"assetId"` + StorageKey string `json:"storageKey"` + URL string `json:"url"` + DataURL string `json:"dataUrl"` + Content string `json:"content"` + } `json:"directMedia"` + } `json:"clips"` + } `json:"timeline"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, err + } + + references := make([]canvasMediaAssetReference, 0) + for _, node := range payload.Nodes { + if !isCanvasMediaKind(node.Type) { + continue + } + resourceID := firstCanvasResourceID(node.Metadata.StorageKey, node.Metadata.Content) + if resourceID == "" { + continue + } + references = append(references, canvasMediaAssetReference{ + AssetID: strings.TrimSpace(node.Metadata.AssetID), ResourceID: resourceID, + }) + } + for _, clip := range payload.Timeline.Clips { + media := clip.DirectMedia + if media == nil || !isCanvasMediaKind(media.Kind) { + continue + } + resourceID := firstCanvasResourceID(media.StorageKey, media.URL, media.DataURL, media.Content) + if resourceID == "" { + continue + } + references = append(references, canvasMediaAssetReference{ + AssetID: strings.TrimSpace(media.AssetID), ResourceID: resourceID, + }) + } + return references, nil +} + +func firstCanvasResourceID(values ...string) string { + for _, value := range values { + if resourceID := canvasResourceID(value); resourceID != "" { + return resourceID + } + } + return "" +} + +func isCanvasMediaKind(kind string) bool { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "image", "video", "audio": + return true + default: + return false + } +} diff --git a/backend/internal/service/resource_delete_test.go b/backend/internal/service/resource_delete_test.go index 502e0f8b8..7fc837e6c 100644 --- a/backend/internal/service/resource_delete_test.go +++ b/backend/internal/service/resource_delete_test.go @@ -312,6 +312,98 @@ func TestExpiredArchivedAssetCleanupRespectsCanvasReferencesAndUsesDeletionOutbo } } +func TestDetachedResourceCleanupRemovesOrphanAndKeepsAssetBackedResource(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + old := time.Now().Add(-48 * time.Hour) + orphan := model.Resource{ + ID: "resource-detached", UserID: "user-1", Status: model.ResourceStatusReady, + Provider: "unsupported-test-provider", ObjectKey: "users/user-1/image/detached.png", + CreatedAt: old, UpdatedAt: old, + } + backed := model.Resource{ + ID: "resource-backed", UserID: "user-1", Status: model.ResourceStatusReady, + Provider: "local", ObjectKey: "users/user-1/image/backed.png", + CreatedAt: old, UpdatedAt: old, + } + asset := model.Asset{ + ID: "asset-backed", UserID: "user-1", Title: "素材库图片", + PayloadJSON: `{"data":{"storageKey":"resource:resource-backed"}}`, CreatedAt: old, UpdatedAt: old, + } + for _, item := range []any{&orphan, &backed, &asset} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + if err := svc.cleanupDetachedUserResources("user-1", []model.Resource{orphan, backed}); err != nil { + t.Fatalf("cleanupDetachedUserResources() error = %v", err) + } + + var orphanCount, backedCount int64 + if err := db.Model(&model.Resource{}).Where("id = ?", orphan.ID).Count(&orphanCount).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.Resource{}).Where("id = ?", backed.ID).Count(&backedCount).Error; err != nil { + t.Fatal(err) + } + if orphanCount != 0 || backedCount != 1 { + t.Fatalf("cleanup result: orphan=%d backed=%d", orphanCount, backedCount) + } +} + +func TestResourceCleanupCandidatesUseStatusSpecificRetention(t *testing.T) { + _, db, _ := newResourceDeletionTestService(t) + now := time.Now() + resources := []model.Resource{ + {ID: "pending-old", UserID: "user-1", Status: model.ResourceStatusPending, CreatedAt: now, UpdatedAt: now.Add(-2 * time.Hour)}, + {ID: "pending-fresh", UserID: "user-1", Status: model.ResourceStatusPending, CreatedAt: now, UpdatedAt: now.Add(-30 * time.Minute)}, + {ID: "ready-old", UserID: "user-1", Status: model.ResourceStatusReady, CreatedAt: now.Add(-25 * time.Hour), UpdatedAt: now}, + {ID: "ready-fresh", UserID: "user-1", Status: model.ResourceStatusReady, CreatedAt: now.Add(-23 * time.Hour), UpdatedAt: now}, + } + for index := range resources { + if err := db.Create(&resources[index]).Error; err != nil { + t.Fatal(err) + } + } + + candidates, err := repository.New(db).ResourceCleanupCandidates(now.Add(-time.Hour), now.Add(-24*time.Hour), 100) + if err != nil { + t.Fatal(err) + } + ids := map[string]bool{} + for _, resource := range candidates { + ids[resource.ID] = true + } + if !ids["pending-old"] || !ids["ready-old"] || ids["pending-fresh"] || ids["ready-fresh"] { + t.Fatalf("cleanup candidates = %#v", ids) + } +} + +func TestDetachedFailedResourceWithoutObjectKeyNeedsNoDeletionJob(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + resource := model.Resource{ + ID: "resource-failed-empty", UserID: "user-1", Status: model.ResourceStatusFailed, + CreatedAt: time.Now().Add(-2 * time.Hour), UpdatedAt: time.Now().Add(-2 * time.Hour), + } + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + + if err := svc.cleanupDetachedUserResources(resource.UserID, []model.Resource{resource}); err != nil { + t.Fatal(err) + } + var resourceCount, jobCount int64 + if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&resourceCount).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.ResourceDeletionJob{}).Where("resource_id = ?", resource.ID).Count(&jobCount).Error; err != nil { + t.Fatal(err) + } + if resourceCount != 0 || jobCount != 0 { + t.Fatalf("cleanup result: resource=%d jobs=%d", resourceCount, jobCount) + } +} + func newResourceDeletionTestService(t *testing.T) (*Service, *gorm.DB, string) { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) diff --git a/backend/internal/service/resource_deletion_worker.go b/backend/internal/service/resource_deletion_worker.go index 72d9037a5..9b753795b 100644 --- a/backend/internal/service/resource_deletion_worker.go +++ b/backend/internal/service/resource_deletion_worker.go @@ -4,18 +4,22 @@ import ( "context" "log" "math" + "strings" "time" "infinite-canvas/backend/internal/model" ) const resourceDeletionLease = 2 * time.Minute +const incompleteResourceRetention = time.Hour +const detachedReadyResourceRetention = 24 * time.Hour func (s *Service) startResourceDeletionWorker(ctx context.Context) { s.runWorkerLoop(func(ctx context.Context) { s.drainResourceDeletionJobs(32) s.cleanupStaleAnnouncementImageDrafts() s.cleanupExpiredArchivedAssets() + s.cleanupDetachedResources() ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() lastPeriodicCleanup := time.Now() @@ -28,6 +32,7 @@ func (s *Service) startResourceDeletionWorker(ctx context.Context) { if time.Since(lastPeriodicCleanup) >= time.Hour { s.cleanupStaleAnnouncementImageDrafts() s.cleanupExpiredArchivedAssets() + s.cleanupDetachedResources() lastPeriodicCleanup = time.Now() } } @@ -35,6 +40,93 @@ func (s *Service) startResourceDeletionWorker(ctx context.Context) { }) } +func (s *Service) cleanupDetachedResources() { + now := time.Now() + candidates, err := s.repo.ResourceCleanupCandidates( + now.Add(-incompleteResourceRetention), + now.Add(-detachedReadyResourceRetention), + 500, + ) + if err != nil { + log.Printf("detached resource cleanup query failed: %v", err) + return + } + byUser := map[string][]model.Resource{} + for _, resource := range candidates { + byUser[resource.UserID] = append(byUser[resource.UserID], resource) + } + for userID, resources := range byUser { + if err := s.cleanupDetachedUserResources(userID, resources); err != nil { + log.Printf("detached resource cleanup failed for user %s: %v", userID, err) + } + } +} + +func (s *Service) cleanupDetachedUserResources(userID string, candidates []model.Resource) error { + s.storageMu.Lock() + defer s.storageMu.Unlock() + resourceIDs := make([]string, 0, len(candidates)) + candidateSet := make(map[string]struct{}, len(candidates)) + for _, resource := range candidates { + resourceIDs = append(resourceIDs, resource.ID) + candidateSet[resource.ID] = struct{}{} + } + snapshot, err := s.repo.ResourceReferenceSnapshot(userID, "", resourceIDs) + if err != nil { + return err + } + referenced := map[string]struct{}{} + for _, reference := range snapshot.Direct { + if _, exists := candidateSet[reference.ResourceID]; exists { + referenced[reference.ResourceID] = struct{}{} + } + } + for _, document := range snapshot.Documents { + for resourceID := range documentReferencedResourceIDs(document.PrimaryJSON, candidateSet) { + referenced[resourceID] = struct{}{} + } + for resourceID := range documentReferencedResourceIDs(document.SecondaryJSON, candidateSet) { + referenced[resourceID] = struct{}{} + } + } + detached := make([]model.Resource, 0, len(candidates)) + for _, resource := range candidates { + if _, exists := referenced[resource.ID]; !exists { + detached = append(detached, resource) + } + } + if len(detached) == 0 { + return nil + } + detachedIDs := make([]string, 0, len(detached)) + for _, resource := range detached { + detachedIDs = append(detachedIDs, resource.ID) + } + physicalObjects := map[string]*model.Resource{} + for index := range detached { + resource := &detached[index] + if strings.TrimSpace(resource.ObjectKey) == "" { + continue + } + sharedCount, countErr := s.repo.ResourceStorageReferenceCount(resource, detachedIDs) + if countErr != nil { + return countErr + } + if sharedCount == 0 { + physicalObjects[resourceStorageIdentity(resource)] = resource + } + } + deletionJobs := resourceDeletionJobs(userID, physicalObjects) + if err := s.repo.DeleteDetachedResources(detached, deletionJobs); err != nil { + return err + } + log.Printf("detached resource cleanup: removed %d resource rows for user %s", len(detached), userID) + if len(deletionJobs) > 0 { + go s.drainResourceDeletionJobs(len(deletionJobs)) + } + return nil +} + func (s *Service) cleanupExpiredArchivedAssets() { policy, err := s.RuntimePolicy() if err != nil { diff --git a/backend/internal/service/upload_quota_test.go b/backend/internal/service/upload_quota_test.go index d301bb9c9..52a2e2178 100644 --- a/backend/internal/service/upload_quota_test.go +++ b/backend/internal/service/upload_quota_test.go @@ -70,7 +70,16 @@ func TestReserveUserUploadQuotaRejectsTotalStoredFilesAtLimit(t *testing.T) { func TestAccountFileStorageUsageUsesStoredFilePolicy(t *testing.T) { svc := newResourceTestService(t) - if err := svc.repo.Create(&model.Resource{ID: "resource-1", UserID: "user-1", Status: model.ResourceStatusReady, Size: 3 << 20}); err != nil { + if err := svc.repo.Create(&model.Resource{ID: "resource-1", UserID: "user-1", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "ready.png", Size: 3 << 20}); err != nil { + t.Fatal(err) + } + if err := svc.repo.Create(&model.Resource{ID: "resource-duplicate", UserID: "user-1", Status: model.ResourceStatusReady, Provider: "", ObjectKey: "ready.png", Size: 3 << 20}); err != nil { + t.Fatal(err) + } + if err := svc.repo.Create(&model.Resource{ID: "resource-failed", UserID: "user-1", Status: model.ResourceStatusFailed, Provider: "local", ObjectKey: "failed.png", Size: 7 << 20}); err != nil { + t.Fatal(err) + } + if err := svc.repo.Create(&model.Resource{ID: "resource-pending", UserID: "user-1", Status: model.ResourceStatusPending, Provider: "local", ObjectKey: "pending.png", Size: 11 << 20}); err != nil { t.Fatal(err) } if err := svc.repo.Create(&model.SessionFile{ID: "session-file-1", UserID: "user-1", SessionID: "session-1", Size: 2 << 20}); err != nil { diff --git a/backend/internal/service/user_data.go b/backend/internal/service/user_data.go index 7dbaccf51..2f7224d04 100644 --- a/backend/internal/service/user_data.go +++ b/backend/internal/service/user_data.go @@ -82,6 +82,11 @@ func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataS if existingErr != nil && !errors.Is(existingErr, gorm.ErrRecordNotFound) { return UserDataSummary{}, existingErr } + if existing != nil && existing.PayloadJSON != asset.PayloadJSON { + if err := s.validateAssetCanvasReferences(userID, asset); err != nil { + return UserDataSummary{}, err + } + } existingBytes := int64(0) if existing != nil { existingBytes = int64(len([]byte(existing.PayloadJSON))) @@ -139,6 +144,9 @@ func (s *Service) ReplaceUserAssets(userID string, req AssetsSyncRequest) ([]jso } s.storageMu.Lock() defer s.storageMu.Unlock() + if err := s.validateAssetReplacementCanvasReferences(userID, assets); err != nil { + return nil, err + } usage, err := s.repo.UserStorageUsage(userID) if err != nil { return nil, err @@ -200,6 +208,9 @@ func (s *Service) UpsertUserCanvasProject(userID string, raw json.RawMessage) (U } s.storageMu.Lock() defer s.storageMu.Unlock() + if err := s.validateCanvasMediaAssets(userID, raw); err != nil { + return UserDataSummary{}, err + } existing, existingErr := s.repo.CanvasProjectForUser(userID, project.ID) if existingErr != nil && !errors.Is(existingErr, gorm.ErrRecordNotFound) { return UserDataSummary{}, existingErr @@ -245,6 +256,11 @@ func (s *Service) ReplaceUserCanvasProjects(userID string, req CanvasProjectsSyn } s.storageMu.Lock() defer s.storageMu.Unlock() + for _, raw := range req.Projects { + if err := s.validateCanvasMediaAssets(userID, raw); err != nil { + return nil, err + } + } usage, err := s.repo.UserStorageUsage(userID) if err != nil { return nil, err diff --git a/backend/internal/service/user_data_test.go b/backend/internal/service/user_data_test.go index db74e2658..e2a29f9d3 100644 --- a/backend/internal/service/user_data_test.go +++ b/backend/internal/service/user_data_test.go @@ -117,3 +117,105 @@ func TestValidateSyncedPayloadRejectsNestedInlineMedia(t *testing.T) { } } } + +func TestCanvasMediaAssetReferencesCollectsNodesAndTimeline(t *testing.T) { + raw := json.RawMessage(`{ + "nodes":[ + {"type":"image","metadata":{"assetId":"asset-image","storageKey":"resource:resource-image"}}, + {"type":"text","metadata":{"assetId":"asset-text","content":"/api/resources/not-media/file"}}, + {"type":"video","metadata":{"content":"https://cdn.example.com/external.mp4"}} + ], + "timeline":{"clips":[ + {"directMedia":{"kind":"audio","assetId":"asset-audio","url":"/api/resources/resource-audio/file"}}, + {"directMedia":{"kind":"text","content":"/api/resources/not-timeline-media/file"}} + ]} + }`) + + references, err := canvasMediaAssetReferences(raw) + if err != nil { + t.Fatal(err) + } + if len(references) != 2 { + t.Fatalf("references = %#v", references) + } + if references[0].AssetID != "asset-image" || references[0].ResourceID != "resource-image" { + t.Fatalf("node reference = %#v", references[0]) + } + if references[1].AssetID != "asset-audio" || references[1].ResourceID != "resource-audio" { + t.Fatalf("timeline reference = %#v", references[1]) + } +} + +func TestValidateCanvasMediaAssetsRejectsResourceWithoutAsset(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + resource := model.Resource{ + ID: "resource-canvas-orphan", UserID: "user-1", Status: model.ResourceStatusReady, + Provider: "local", ObjectKey: "users/user-1/image/orphan.png", + } + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + raw := json.RawMessage(`{ + "id":"canvas-1", + "nodes":[{"id":"node-1","type":"image","metadata":{"storageKey":"resource:resource-canvas-orphan","content":"/api/resources/resource-canvas-orphan/file"}}] + }`) + + err := svc.validateCanvasMediaAssets("user-1", raw) + if err == nil || !strings.Contains(err.Error(), "尚未进入素材库") { + t.Fatalf("validateCanvasMediaAssets() error = %v", err) + } +} + +func TestValidateCanvasMediaAssetsAcceptsMatchingNodeAndTimelineAssets(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + resources := []model.Resource{ + {ID: "resource-node", UserID: "user-1", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "users/user-1/image/node.png"}, + {ID: "resource-timeline", UserID: "user-1", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "users/user-1/video/timeline.mp4"}, + } + assets := []model.Asset{ + {ID: "asset-node", UserID: "user-1", PayloadJSON: `{"id":"asset-node","data":{"storageKey":"resource:resource-node"}}`}, + {ID: "asset-timeline", UserID: "user-1", PayloadJSON: `{"id":"asset-timeline","data":{"url":"/api/resources/resource-timeline/file"}}`}, + } + for index := range resources { + if err := db.Create(&resources[index]).Error; err != nil { + t.Fatal(err) + } + } + for index := range assets { + if err := db.Create(&assets[index]).Error; err != nil { + t.Fatal(err) + } + } + raw := json.RawMessage(`{ + "id":"canvas-1", + "nodes":[{"id":"node-1","type":"image","metadata":{"assetId":"asset-node","storageKey":"resource:resource-node"}}], + "timeline":{"clips":[{"id":"clip-1","directMedia":{"kind":"video","assetId":"asset-timeline","storageKey":"resource:resource-timeline"}}]} + }`) + + if err := svc.validateCanvasMediaAssets("user-1", raw); err != nil { + t.Fatalf("validateCanvasMediaAssets() error = %v", err) + } +} + +func TestValidateCanvasMediaAssetsRejectsMismatchedAsset(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + resources := []model.Resource{ + {ID: "resource-canvas", UserID: "user-1", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "users/user-1/image/canvas.png"}, + {ID: "resource-other", UserID: "user-1", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "users/user-1/image/other.png"}, + } + for index := range resources { + if err := db.Create(&resources[index]).Error; err != nil { + t.Fatal(err) + } + } + asset := model.Asset{ID: "asset-wrong", UserID: "user-1", PayloadJSON: `{"data":{"storageKey":"resource:resource-other"}}`} + if err := db.Create(&asset).Error; err != nil { + t.Fatal(err) + } + raw := json.RawMessage(`{"nodes":[{"type":"image","metadata":{"assetId":"asset-wrong","storageKey":"resource:resource-canvas"}}]}`) + + err := svc.validateCanvasMediaAssets("user-1", raw) + if err == nil || !strings.Contains(err.Error(), "不一致") { + t.Fatalf("validateCanvasMediaAssets() error = %v", err) + } +} diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 820773f09..fedc483b0 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -15,7 +15,7 @@ description: 影策后端核心持久化表与状态边界 容器内命令 `migrate-schema up` 应用缺失迁移,`migrate-schema status` 输出当前和期望版本,`migrate-schema verify` 只检查兼容性。`CANVAS_AUTO_MIGRATE=true` 仅用于开发和旧部署兼容;受管生产部署设置为 `false`,并由 Compose 的一次性 `migrate` 服务先完成迁移。后续迁移必须新增版本,不得修改已经发布迁移的实现或校验和。 -当前结构版本为 4。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引。 +当前结构版本为 5。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构。 版本 3 会将 `wardrobe`、`weapon`、`accessory` 迁移为 `prop`,将 `style` 迁移为 `material`,并把原先归入 `other` 的图片、视频、音频和 3D 模型迁移为 `material`。迁移只合并重复的待确认候选:保留最早记录,其余标记为 `ignored`;已经确认的资产不会被自动删除或归并。 @@ -148,4 +148,8 @@ description: 影策后端核心持久化表与状态边界 `resources.upload_key` 保存客户端 `X-Idempotency-Key` 的 SHA-256 摘要。相同用户重复提交同一逻辑上传时复用已有资源;失败重试继续使用原对象路径,避免对象存储已经接收请求但响应丢失时产生第二份物理对象。未提供幂等标识的后台生成资源保持 `NULL`,不参与该唯一约束。 +个人画布中的图片、视频和音频采用 `Asset -> Resource` 所有权约束:前端先提交素材,再提交带 `assetId` 的画布节点或时间线直连媒体;后端保存画布前校验资源属于当前用户、状态为 `ready`、素材存在且引用同一资源。素材更新、全量替换和删除也会反向检查已有画布,不能把仍在使用的资源从素材库中移除或替换成其他资源。登录历史账号时,前端会按资源键补建缺失素材并回写 `assetId`。这项修复复用现有表和 Outbox,不新增结构迁移。 + +账号文件容量只统计 `ready` 资源,并按 Provider、Endpoint、Bucket、ObjectKey 对同一物理对象去重;上传中或失败记录不占用已存储容量,`session_files` 仍按实际大小计入。后台每小时检查资源生命周期:超过 1 小时的 `pending/failed` 记录,以及超过 24 小时仍没有任何素材、画布、任务或其他业务引用的 `ready` 记录,会在再次校验引用后删除数据库记录并写入 `resource_deletion_jobs`,由幂等 Worker 清理物理对象。 + 素材状态为 `archived` 时进入回收站。后台按“资源与请求策略”中的 `recycleBinRetentionDays` 定期扫描过期项;`0` 表示永久保留。自动清理与用户手动彻底删除复用同一 service 引用校验和 `resource_deletion_jobs` Outbox,仍被画布、项目、任务或其他业务记录引用的素材不会被删除。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 199f25b29..040b34fd5 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -428,6 +428,13 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [x] 资源引用识别改为显式字段合同,未登记的字段名和自由文本不会被推断为资源引用。 - [x] 后端素材删除改为数据库事务 + `resource_deletion_jobs` Outbox,数据库失败时物理文件保持不动。 - [x] Outbox Worker 本地文件删除、对象不存在幂等成功和失败退避测试通过。 +- [x] 画布上传、媒体工具产物、导演台输出、ZIP 导入和时间线直连媒体统一先写入素材库;远端同步按“素材后端确认后再保存画布”的顺序执行。 +- [x] 后端保存画布时校验 `assetId`、资源归属、就绪状态和素材资源一致性;素材更新、替换与删除不能破坏已有画布引用。 +- [x] 登录会修复历史画布缺失的素材记录与 `assetId`;后台按状态保留期回收无任何业务引用的资源,并通过删除 Outbox 清理物理对象。 +- [x] 账号容量只统计 `ready` 资源并按物理对象去重,上传中、失败记录和重复 Resource 行不会形成虚假容量占用。 +- [ ] 在个人画布分别上传图片、视频、音频和时间线媒体,刷新后确认 `/assets` 均可见对应素材、画布 `assetId` 不丢失,账号容量只增长一次。 +- [ ] 使用修复前已有“画布存在资源但素材缺失”的账号登录,确认素材自动补建并同步;再准备超过 24 小时且无任何引用的孤儿对象,确认后台最终删除资源记录和物理对象、容量同步下降。 +- [ ] 模拟资源上传成功后立即断网,确认画布结构不会以缺失素材的状态写入服务端;恢复网络后自动重试完成素材与画布同步。 - [ ] 使用真实 OSS 配置验证对象删除成功、权限失败后的退避重试和多实例租约接管。 - [ ] 浏览器真实环境验证登录快照慢响应期间工作区保持不可写,以及删除后立即刷新不复活素材。 diff --git a/web/src/components/canvas/director/canvas-director-workbench.tsx b/web/src/components/canvas/director/canvas-director-workbench.tsx index 5c1948808..efd39af66 100644 --- a/web/src/components/canvas/director/canvas-director-workbench.tsx +++ b/web/src/components/canvas/director/canvas-director-workbench.tsx @@ -23,6 +23,7 @@ import { createDirectorActor, createDirectorBillboard, createDirectorCamera, cre import { describeDirectorSaveStatus, resolveDirectorCloseOutcome, shouldBlockDirectorUnload, shouldOfferDirectorDraftRecovery } from "@/lib/canvas/director/director-save-wiring"; import { useDirectorSaveCoordinator } from "@/components/canvas/director/use-director-save-coordinator"; import { uploadMediaFile } from "@/services/file-storage"; +import { saveRemoteUserDataNow } from "@/services/user-data-sync"; import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store"; import { useDirectorWorkbenchStore } from "@/stores/canvas/use-director-workbench-store"; import { useThemeStore } from "@/stores/use-theme-store"; @@ -418,6 +419,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco if (!file || !/\.(glb|gltf)$/i.test(file.name)) return; const uploaded = await uploadMediaFile(file, "model"); const assetId = addAsset({ kind: "model", title: file.name.replace(/\.(glb|gltf)$/i, ""), coverUrl: "", tags: ["3D模型"], source: "导演台", data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: file.name }, metadata: { source: "director" } }); + await saveRemoteUserDataNow(); const asset = useAssetStore.getState().assets.find((item): item is ModelAsset => item.id === assetId && item.kind === "model"); if (asset) addModelAsset(asset); message.success("3D 模型已加入场景和素材库"); diff --git a/web/src/pages/canvas/index.tsx b/web/src/pages/canvas/index.tsx index 5cc9bb24c..03993186e 100644 --- a/web/src/pages/canvas/index.tsx +++ b/web/src/pages/canvas/index.tsx @@ -14,7 +14,7 @@ import { CanvasCreateCard } from "@/components/canvas/canvas-project-card"; import { CanvasFolderCard } from "@/components/canvas/canvas-folder-card"; import { CanvasHistoryDrawer } from "@/components/canvas/canvas-history-drawer"; import type { CanvasExportFile } from "@/types/canvas-export"; -import type { CanvasNodeData } from "@/types/canvas"; +import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store"; import { exportCanvasProjects } from "@/lib/canvas/canvas-export"; @@ -25,6 +25,7 @@ import { loadCanvasProjectPage } from "@/lib/workspace-route-modules"; import { resourceFileUrl, resourceStorageKey, uploadResourceFile } from "@/services/api/resources"; import { primeResourceBlobCache } from "@/services/resource-blob-cache"; import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; +import { ensureCanvasNodeAsset } from "@/services/project-asset-sync"; export default function CanvasPage() { const { message } = App.useApp(); @@ -206,8 +207,69 @@ export default function CanvasPage() { }; }; - const remappedNodes = (item.project.nodes || []).map(remapNodeMedia); - updateProject(importedProjectId, { nodes: remappedNodes }); + let remappedNodes = (item.project.nodes || []).map(remapNodeMedia); + let remappedTimeline = item.project.timeline + ? { + ...item.project.timeline, + clips: item.project.timeline.clips.map((clip) => { + const directMedia = clip.directMedia; + if (!directMedia?.storageKey) return clip; + const mapped = storageKeyMap.get(directMedia.storageKey); + return mapped + ? { + ...clip, + directMedia: { ...directMedia, storageKey: mapped.storageKey, url: mapped.url, dataUrl: directMedia.dataUrl ? mapped.url : directMedia.dataUrl, content: directMedia.content ? mapped.url : directMedia.content }, + } + : clip; + }), + } + : undefined; + updateProject(importedProjectId, { nodes: remappedNodes, timeline: remappedTimeline }); + + const assetIdByStorageKey = new Map(); + for (let index = 0; index < remappedNodes.length; index += 1) { + const node = remappedNodes[index]; + const isMedia = node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio; + if (!isMedia || !node.metadata?.content) continue; + const storageKey = node.metadata.storageKey || ""; + let assetId = storageKey ? assetIdByStorageKey.get(storageKey) : undefined; + if (!assetId) { + const result = await ensureCanvasNodeAsset({ canvasId: importedProjectId, domainProjectId: item.project.projectId, node, source: "canvas-upload" }); + assetId = result.assetId; + if (storageKey) assetIdByStorageKey.set(storageKey, assetId); + } + remappedNodes[index] = { ...node, metadata: { ...node.metadata, assetId } }; + } + if (remappedTimeline) { + const clips: typeof remappedTimeline.clips = []; + for (const clip of remappedTimeline.clips) { + const media = clip.directMedia; + const content = media?.url || media?.dataUrl || media?.content || ""; + if (!media || media.assetId || !media.storageKey || !content || media.kind === "text") { + clips.push(clip); + continue; + } + let assetId = assetIdByStorageKey.get(media.storageKey); + if (!assetId) { + const type = media.kind === "audio" ? CanvasNodeType.Audio : media.kind === "video" ? CanvasNodeType.Video : CanvasNodeType.Image; + const node: CanvasNodeData = { + id: media.id, + type, + title: media.title, + position: { x: 0, y: 0 }, + width: media.width || 320, + height: media.height || (type === CanvasNodeType.Audio ? 120 : 240), + metadata: { content, storageKey: media.storageKey, naturalWidth: media.width, naturalHeight: media.height, durationMs: media.durationMs, bytes: media.bytes, mimeType: media.mimeType }, + }; + const result = await ensureCanvasNodeAsset({ canvasId: importedProjectId, domainProjectId: item.project.projectId, node, source: "canvas-upload" }); + assetId = result.assetId; + assetIdByStorageKey.set(media.storageKey, assetId); + } + clips.push({ ...clip, directMedia: { ...media, assetId } }); + } + remappedTimeline = { ...remappedTimeline, clips }; + } + updateProject(importedProjectId, { nodes: remappedNodes, timeline: remappedTimeline }); await Promise.all( (item.drawingDocuments || []).map((document) => { diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 46fd7a2b2..3ba681f6a 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -1346,11 +1346,13 @@ function InfiniteCanvasPage() { setNodes((current) => [...current, created]); setConnections((current) => [...current, connection]); setSelectedNodeIds(new Set([created.id])); + const result = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId: currentProject?.projectId, node: created, source: "canvas-manual" }); + setNodes((current) => current.map((item) => item.id === created.id ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item)); message.success("候选图片已添加到画布并连接到排查节点"); } catch (error) { message.error(error instanceof Error ? error.message : "候选图片添加失败"); } - }, [message, portraitClearanceNodeId, setConnections, setNodes, setSelectedNodeIds]); + }, [currentProject?.projectId, message, portraitClearanceNodeId, projectId, setConnections, setNodes, setSelectedNodeIds]); const pendingConnectionSourceNode = pendingConnectionCreate?.connection.handleType === "source" ? nodeById.get(pendingConnectionCreate.connection.nodeId) : null; const canCreateDrawingFromConnection = !pendingConnectionCreate?.batchSourceNodeIds?.length && pendingConnectionSourceNode?.type === CanvasNodeType.Image && Boolean(pendingConnectionSourceNode.metadata?.content); @@ -1462,6 +1464,7 @@ function InfiniteCanvasPage() { const { applyDirectorOutput, createDirectorShot, openDirectorWorkbench, saveDirectorScene } = useCanvasDirector({ projectId, + domainProjectId: currentProject?.projectId, directorNodeId, directorScenes: currentProject?.directorScenes || [], nodesRef, @@ -1654,12 +1657,16 @@ function InfiniteCanvasPage() { message.loading({ key: feedbackKey, content: "正在保存并上传到方舟素材库...", duration: 0 }); try { let resourceID = resourceIdFromStorageKey(node.metadata.storageKey); + let persistedNode = node; if (!resourceID) { const uploaded = await uploadImage(node.metadata.content); resourceID = resourceIdFromStorageKey(uploaded.storageKey); if (!resourceID) throw new Error("图片未能保存到系统素材库,请检查对象存储配置后重试"); handleConfigNodeChange(node.id, imageMetadata(uploaded)); + persistedNode = { ...node, metadata: { ...node.metadata, ...imageMetadata(uploaded) } }; } + const asset = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId: currentProject?.projectId, node: persistedNode, source: "canvas-manual" }); + handleConfigNodeChange(node.id, { assetId: asset.assetId }); await syncResourceToArkPrivateAsset(resourceID); message.success({ key: feedbackKey, content: "已同步到方舟素材库,Seedance 将自动复用该素材", duration: 4 }); } catch (error) { @@ -1667,7 +1674,7 @@ function InfiniteCanvasPage() { } finally { setArkPrivateAssetUploadNodeId((current) => current === node.id ? null : current); } - }, [arkPrivateAssetUploadNodeId, handleConfigNodeChange, message]); + }, [arkPrivateAssetUploadNodeId, currentProject?.projectId, handleConfigNodeChange, message, projectId]); const confirmUploadNodeImageToArkPrivateAsset = useCallback((node: CanvasNodeData) => { Modal.confirm({ diff --git a/web/src/pages/canvas/use-canvas-director.ts b/web/src/pages/canvas/use-canvas-director.ts index 5e1d62ee5..0b625a6b0 100644 --- a/web/src/pages/canvas/use-canvas-director.ts +++ b/web/src/pages/canvas/use-canvas-director.ts @@ -9,12 +9,14 @@ import { createDirectorSceneFromTemplate, type DirectorTemplateId } from "@/lib/ import { mergeDirectorOutputPreview, upsertDirectorSceneById } from "@/lib/canvas/director/director-session"; import { uploadImage } from "@/services/image-storage"; import { uploadMediaFile } from "@/services/file-storage"; +import { ensureCanvasNodeAsset } from "@/services/project-asset-sync"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas"; import type { DirectorScene, DirectorSceneOutput } from "@/types/director"; type UseCanvasDirectorOptions = { projectId: string; + domainProjectId?: string; directorNodeId: string | null; directorScenes: DirectorScene[]; nodesRef: { current: CanvasNodeData[] }; @@ -40,6 +42,7 @@ function currentDirectorScenes(projectId: string, fallback: DirectorScene[]) { } export function useCanvasDirector({ projectId, + domainProjectId, directorNodeId, directorScenes, nodesRef, @@ -196,13 +199,23 @@ export function useCanvasDirector({ videoCameraMovePrompt: output.prompt, referenceAssetNodeIds, }; - const finalizedNodes = nextNodes.map((item) => item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, ...directorMetadata } } : item); + const mediaNodes = nextNodes.filter((item) => item.id === previewId || Boolean(clayVideoId && item.id === clayVideoId)); + const assetIds = new Map(); + for (const mediaNode of mediaNodes) { + const result = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node: mediaNode, source: "canvas-manual" }); + assetIds.set(mediaNode.id, result.assetId); + } + const finalizedNodes = nextNodes.map((item) => { + const assetId = assetIds.get(item.id); + if (assetId) return { ...item, metadata: { ...item.metadata, assetId } }; + return item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, ...directorMetadata } } : item; + }); nodesRef.current = finalizedNodes; connectionsRef.current = nextConnections; setNodes(finalizedNodes); setConnections(nextConnections); saveDirectorScene(mergedScene); - }, [connectionsRef, directorNodeId, nodesRef, projectId, saveDirectorScene, setConnections, setNodes]); + }, [connectionsRef, directorNodeId, domainProjectId, nodesRef, projectId, saveDirectorScene, setConnections, setNodes]); return { applyDirectorOutput, createDirectorShot, openDirectorWorkbench, saveDirectorScene }; } diff --git a/web/src/pages/canvas/use-canvas-media-tools.ts b/web/src/pages/canvas/use-canvas-media-tools.ts index 6f7da9ae1..6af2c697e 100644 --- a/web/src/pages/canvas/use-canvas-media-tools.ts +++ b/web/src/pages/canvas/use-canvas-media-tools.ts @@ -126,6 +126,25 @@ export function useCanvasMediaTools({ } }, [message, nodesRef]); + const persistMediaNodes = useCallback(async (mediaNodes: CanvasNodeData[]) => { + const assetIds = new Map(); + for (const mediaNode of mediaNodes) { + try { + const result = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node: mediaNode, source: "canvas-manual" }); + assetIds.set(mediaNode.id, result.assetId); + } catch (error) { + message.warning(`媒体节点已创建,但素材库写入失败:${error instanceof Error ? error.message : "未知错误"}`); + } + } + if (assetIds.size > 0) { + setNodes((current) => current.map((item) => { + const assetId = assetIds.get(item.id); + return assetId ? { ...item, metadata: { ...item.metadata, assetId } } : item; + })); + } + return assetIds; + }, [domainProjectId, message, projectId, setNodes]); + const createImageReversePromptNodes = useCallback((node: CanvasNodeData) => { if (node.type !== CanvasNodeType.Image || !node.metadata?.content) { message.warning("图片节点为空,无法反推提示词"); @@ -184,7 +203,8 @@ export function useCanvasMediaTools({ setSelectedNodeIds(new Set([childId])); setDialogNodeId(childId); setCropNodeId(null); - }, [setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]); + await persistMediaNodes([child]); + }, [persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]); const saveAnnotatedImageNode = useCallback(async (node: CanvasNodeData, dataUrl: string) => { const image = await uploadImage(dataUrl); @@ -197,8 +217,9 @@ export function useCanvasMediaTools({ setSelectedConnectionId(null); setDialogNodeId(null); setAnnotationNodeId(null); + await persistMediaNodes([child]); message.success("标注图片已保存为新节点"); - }, [message, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]); + }, [message, persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]); const openVideoFrameExtractor = useCallback((node: CanvasNodeData) => { if (!node.metadata?.content) { @@ -251,6 +272,7 @@ export function useCanvasMediaTools({ setConnections(nextConnections); setSelectedNodeIds(selection); setSelectedConnectionId(null); + await persistMediaNodes(frameNodes); const failedCount = captured.failures.length + uploadFailures.length; progress.done(failedCount ? `已提取 ${frameNodes.length} 帧,${failedCount} 帧失败` : `已提取 ${frameNodes.length} 帧并创建图片节点`); if (failedCount) message.warning(`${failedCount} 个时间点提取失败,其余画面已创建`); @@ -262,7 +284,7 @@ export function useCanvasMediaTools({ extractingVideoFramesNodeIdRef.current = null; setExtractingVideoFramesNodeId(null); } - }, [connectionsRef, message, nodesRef, selectedNodeIdsRef, setConnections, setNodes, setSelectedConnectionId, setSelectedNodeIds, startUploadStatus]); + }, [connectionsRef, message, nodesRef, persistMediaNodes, selectedNodeIdsRef, setConnections, setNodes, setSelectedConnectionId, setSelectedNodeIds, startUploadStatus]); const extractAudioFromVideo = useCallback((node: CanvasNodeData) => { if (!node.metadata?.content) { @@ -502,6 +524,7 @@ export function useCanvasMediaTools({ setSelectedNodeIds(selection); setSelectedConnectionId(null); setDialogNodeId(null); + await persistMediaNodes([mergedNode]); setMergeVideoProgress({ phase: "encoding", progress: 100 }); message.success(`已合并 ${videos.length} 段视频,成片节点已添加`); } catch (error) { @@ -510,7 +533,7 @@ export function useCanvasMediaTools({ mergeVideoRunningRef.current = false; window.setTimeout(() => setMergeVideoProgress(null), 700); } - }, [connectionsRef, message, nodesRef, selectedNodeIdsRef, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]); + }, [connectionsRef, message, nodesRef, persistMediaNodes, selectedNodeIdsRef, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]); const mergeSelectedVideos = useCallback(() => mergeVideosByIds(Array.from(selectedNodeIdsRef.current)), [mergeVideosByIds, selectedNodeIdsRef]); @@ -539,8 +562,9 @@ export function useCanvasMediaTools({ setSelectedNodeIds(new Set(childNodes.map((child) => child.id))); setSelectedConnectionId(null); setDialogNodeId(null); + await persistMediaNodes(childNodes); message.success(`已切分为 ${childNodes.length} 个子节点`); - }, [message, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]); + }, [message, persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]); const maskEditImageNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => { if (!node.metadata?.content) return; @@ -572,7 +596,11 @@ export function useCanvasMediaTools({ if (!image?.dataUrl) throw new Error("后端任务没有返回图片"); const uploaded = await uploadImage(image.dataUrl); const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height); - setNodes((current) => current.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } } : item)); + const currentNode = nodesRef.current.find((item) => item.id === childId); + if (!currentNode) throw new Error("局部编辑节点已被删除"); + const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } }; + setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item)); + await persistMediaNodes([finalizedNode]); } catch (error) { if (isGenerationCanceled(error)) return; const details = generationErrorMessage(error); @@ -582,7 +610,7 @@ export function useCanvasMediaTools({ finishGenerationRequest(childId, controller); setRunningNodeId(null); } - }, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]); + }, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]); const upscaleImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageUpscaleParams) => { if (!node.metadata?.content) return; @@ -596,7 +624,8 @@ export function useCanvasMediaTools({ setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); setSelectedNodeIds(new Set([childId])); setDialogNodeId(childId); - }, [setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]); + await persistMediaNodes([child]); + }, [persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]); const generateAngleNode = useCallback(async (node: CanvasNodeData, params: CanvasImageAngleParams) => { if (!node.metadata?.content) return; @@ -628,7 +657,11 @@ export function useCanvasMediaTools({ if (!image?.dataUrl) throw new Error("后端任务没有返回图片"); const uploaded = await uploadImage(image.dataUrl); const size = fitNodeSize(uploaded.width, uploaded.height, imageSpec.width, imageSpec.height); - setNodes((current) => current.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } } : item)); + const currentNode = nodesRef.current.find((item) => item.id === childId); + if (!currentNode) throw new Error("视角生成节点已被删除"); + const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } }; + setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item)); + await persistMediaNodes([finalizedNode]); } catch (error) { if (isGenerationCanceled(error)) return; const details = generationErrorMessage(error); @@ -637,7 +670,7 @@ export function useCanvasMediaTools({ finishGenerationRequest(childId, controller); setRunningNodeId(null); } - }, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]); + }, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]); const generateEmotionNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageEmotionPayload) => { if (!node.metadata?.content) return; @@ -689,14 +722,18 @@ export function useCanvasMediaTools({ const composited = await compositeEmotionImage(node.metadata.content, image.dataUrl, payload.editRegion, payload.faceBox); const uploaded = await uploadImage(composited); const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height); - setNodes((current) => current.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt: providerPrompt, ...generationMetadata, emotionEdit } } : item)); + const currentNode = nodesRef.current.find((item) => item.id === childId); + if (!currentNode) throw new Error("表情编辑节点已被删除"); + const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: providerPrompt, ...generationMetadata, emotionEdit } }; + setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item)); + await persistMediaNodes([finalizedNode]); } catch (error) { if (isGenerationCanceled(error)) return; const details = generationErrorMessage(error); message.error(details); setNodes((current) => current.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item)); } finally { finishGenerationRequest(childId, controller); setRunningNodeId(null); } - }, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]); + }, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]); return { angleNodeId, diff --git a/web/src/pages/canvas/use-canvas-upload.ts b/web/src/pages/canvas/use-canvas-upload.ts index d3e73e3b8..f87797a52 100644 --- a/web/src/pages/canvas/use-canvas-upload.ts +++ b/web/src/pages/canvas/use-canvas-upload.ts @@ -116,6 +116,31 @@ export function useCanvasUpload({ } }, [canvasId, domainProjectId, message, queryClient, setNodes]); + const persistTimelineMedia = useCallback(async (media: TimelineDirectMedia) => { + const type = media.kind === "audio" ? CanvasNodeType.Audio : media.kind === "video" ? CanvasNodeType.Video : CanvasNodeType.Image; + const defaults = NODE_DEFAULT_SIZE[type]; + const node: CanvasNodeData = { + id: media.id, + type, + title: media.title, + position: { x: 0, y: 0 }, + width: media.width || defaults.width, + height: media.height || defaults.height, + metadata: { + content: media.url || media.dataUrl || media.content || "", + storageKey: media.storageKey, + naturalWidth: media.width, + naturalHeight: media.height, + durationMs: media.durationMs, + bytes: media.bytes, + mimeType: media.mimeType, + }, + }; + const result = await ensureCanvasNodeAsset({ canvasId, domainProjectId, node, source: "canvas-upload" }); + if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] }); + return result.assetId; + }, [canvasId, domainProjectId, queryClient]); + const createImageFileNode = useCallback(async (file: File, position: Position) => { const progress = startUploadStatus("上传图片", "读取图片文件", domainProjectId ? 4 : 3); try { @@ -343,7 +368,7 @@ export function useCanvasUpload({ try { if (isAudioFile(file)) { const audio = await uploadMediaFile(file, "audio"); - created.push({ + const media: TimelineDirectMedia = { id: `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, kind: "audio", title: file.name, @@ -352,10 +377,12 @@ export function useCanvasUpload({ durationMs: audio.durationMs, bytes: audio.bytes, mimeType: audio.mimeType, - }); + }; + media.assetId = await persistTimelineMedia(media); + created.push(media); } else { const video = await uploadMediaFile(file, "video"); - created.push({ + const media: TimelineDirectMedia = { id: `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, kind: "video", title: file.name, @@ -366,7 +393,9 @@ export function useCanvasUpload({ durationMs: video.durationMs, bytes: video.bytes, mimeType: video.mimeType, - }); + }; + media.assetId = await persistTimelineMedia(media); + created.push(media); } } catch (error) { message.error(error instanceof Error ? `素材上传失败:${error.message}` : "素材上传失败"); @@ -374,7 +403,7 @@ export function useCanvasUpload({ } if (created.length) message.success(`已上传 ${created.length} 个素材到时间线`); return created; - }, [message]); + }, [message, persistTimelineMedia]); // 组装能力闭环:把时间线合成结果(MP4 Blob)上传并创建为新的视频节点放回画布, // 复用上传/持久化/选中逻辑,新节点可继续编辑字幕与样式。 diff --git a/web/src/services/canvas-asset-repair.ts b/web/src/services/canvas-asset-repair.ts new file mode 100644 index 000000000..f12c01b50 --- /dev/null +++ b/web/src/services/canvas-asset-repair.ts @@ -0,0 +1,118 @@ +import { canvasNodeToAsset } from "@/lib/canvas/canvas-node-asset"; +import { useAssetStore, type Asset } from "@/stores/use-asset-store"; +import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store"; +import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; +import type { TimelineDirectMedia } from "@/types/timeline"; + +export type CanvasAssetRepairResult = { + createdAssets: number; + updatedProjects: number; +}; + +export function repairMissingCanvasAssets(): CanvasAssetRepairResult { + const assetStore = useAssetStore.getState(); + const canvasStore = useCanvasStore.getState(); + const assetIdByStorageKey = new Map(); + const storageKeyByAssetId = new Map(); + const knownAssetIds = new Set(); + for (const asset of assetStore.assets) { + knownAssetIds.add(asset.id); + const storageKey = assetStorageKey(asset); + if (storageKey) { + storageKeyByAssetId.set(asset.id, storageKey); + if (!assetIdByStorageKey.has(storageKey)) assetIdByStorageKey.set(storageKey, asset.id); + } + } + + let createdAssets = 0; + let updatedProjects = 0; + for (const project of canvasStore.projects) { + const repaired = repairProject(project, knownAssetIds, assetIdByStorageKey, storageKeyByAssetId, (node) => { + const input = canvasNodeToAsset(node, { canvasId: project.id, source: "canvas-upload" }); + if (!input) return undefined; + const assetId = useAssetStore.getState().addAsset(input); + knownAssetIds.add(assetId); + const storageKey = node.metadata?.storageKey; + if (storageKey) { + assetIdByStorageKey.set(storageKey, assetId); + storageKeyByAssetId.set(assetId, storageKey); + } + createdAssets += 1; + return assetId; + }); + if (!repaired) continue; + canvasStore.updateProject(project.id, repaired); + updatedProjects += 1; + } + return { createdAssets, updatedProjects }; +} + +function repairProject( + project: CanvasProject, + knownAssetIds: Set, + assetIdByStorageKey: Map, + storageKeyByAssetId: Map, + createAsset: (node: CanvasNodeData) => string | undefined, +): Pick | null { + let changed = false; + const nodes = project.nodes.map((node) => { + if (!isDurableMediaNode(node)) return node; + const assetId = matchingAssetId(node.metadata?.assetId, node.metadata?.storageKey, knownAssetIds, assetIdByStorageKey, storageKeyByAssetId) || createAsset(node); + if (!assetId || node.metadata?.assetId === assetId) return node; + changed = true; + return { ...node, metadata: { ...node.metadata, assetId } }; + }); + const timeline = project.timeline + ? { + ...project.timeline, + clips: project.timeline.clips.map((clip) => { + const media = clip.directMedia; + if (!media || media.kind === "text" || !mediaContent(media)) return clip; + const assetId = matchingAssetId(media.assetId, media.storageKey, knownAssetIds, assetIdByStorageKey, storageKeyByAssetId) || createAsset(timelineMediaNode(media)); + if (!assetId || media.assetId === assetId) return clip; + changed = true; + return { ...clip, directMedia: { ...media, assetId } }; + }), + } + : project.timeline; + return changed ? { nodes, timeline } : null; +} + +function matchingAssetId(explicitId: string | undefined, storageKey: string | undefined, knownAssetIds: Set, assetIdByStorageKey: Map, storageKeyByAssetId: Map) { + if (explicitId && knownAssetIds.has(explicitId) && (!storageKey || storageKeyByAssetId.get(explicitId) === storageKey)) return explicitId; + return storageKey ? assetIdByStorageKey.get(storageKey) : undefined; +} + +function isDurableMediaNode(node: CanvasNodeData) { + const isMedia = node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio; + return isMedia && Boolean(node.metadata?.content || node.metadata?.storageKey); +} + +function timelineMediaNode(media: TimelineDirectMedia): CanvasNodeData { + const type = media.kind === "audio" ? CanvasNodeType.Audio : media.kind === "video" ? CanvasNodeType.Video : CanvasNodeType.Image; + return { + id: media.id, + type, + title: media.title, + position: { x: 0, y: 0 }, + width: media.width || 320, + height: media.height || (type === CanvasNodeType.Audio ? 120 : 240), + metadata: { + content: mediaContent(media), + storageKey: media.storageKey, + naturalWidth: media.width, + naturalHeight: media.height, + durationMs: media.durationMs, + bytes: media.bytes, + mimeType: media.mimeType, + }, + }; +} + +function mediaContent(media: TimelineDirectMedia) { + return media.url || media.dataUrl || media.content || ""; +} + +function assetStorageKey(asset: Asset) { + return asset.kind === "text" || asset.kind === "entity" ? undefined : asset.data.storageKey; +} diff --git a/web/src/services/project-asset-sync.ts b/web/src/services/project-asset-sync.ts index 2c77875f9..2935287b8 100644 --- a/web/src/services/project-asset-sync.ts +++ b/web/src/services/project-asset-sync.ts @@ -71,7 +71,12 @@ async function persistCanvasNodeAsset(options: EnsureCanvasNodeAssetOptions): Pr store.updateAsset(asset.id, { category: declaredCategory }); asset = useAssetStore.getState().assets.find((item) => item.id === asset?.id) || asset; } - if (!options.domainProjectId) return { assetId: asset.id, created, linkedToProject: false }; + if (!options.domainProjectId) { + // 个人画布也必须在返回成功前把素材提交到服务端,不能只依赖延迟自动同步。 + await saveRemoteUserDataNow(); + throwIfAborted(options.signal); + return { assetId: asset.id, created, linkedToProject: false }; + } await syncAssetToProject(asset.id, options.domainProjectId, declaredCategory, options.folderId, options.signal); return { assetId: asset.id, created, linkedToProject: true }; } diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index 7b156c818..a801b3231 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -9,6 +9,7 @@ import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store"; +import { repairMissingCanvasAssets } from "@/services/canvas-asset-repair"; let activeRemoteUserId = ""; type RemoteUserDataPhase = "inactive" | "hydrating" | "ready" | "failed"; @@ -25,6 +26,7 @@ let acknowledgedProjects = new Map(); const LOCAL_STORAGE_KEY_PATTERN = /^(image|video|audio|file|video-reference|audio-reference):/; export async function syncRemoteUserData(userId?: string | null) { + let repairedCanvasAssets = false; await withRemoteUserDataSyncExclusive(async () => { activeRemoteUserId = userId || ""; acknowledgedProjects.clear(); @@ -42,6 +44,8 @@ export async function syncRemoteUserData(userId?: string | null) { // 这里只替换结构化记录,不在登录阶段解析图片/视频/音频 URL;媒体由实际使用方按需解析。 useCanvasStore.getState().replaceProjects(snapshot.projects); useAssetStore.getState().replaceAssets(snapshot.assets); + const repair = repairMissingCanvasAssets(); + repairedCanvasAssets = repair.createdAssets > 0 || repair.updatedProjects > 0; await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]); acknowledgedProjects = new Map(snapshot.projects.map((project) => [project.id, project])); acknowledgedAssets = new Map(snapshot.assets.map((asset) => [asset.id, asset])); @@ -51,6 +55,7 @@ export async function syncRemoteUserData(userId?: string | null) { throw error; } }); + if (repairedCanvasAssets) await saveRemoteUserDataNow(); } export function installRemoteUserDataAutoSync() { @@ -167,6 +172,9 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) { for (const node of proj.nodes) { if (node.metadata?.assetId) activeAssetIds.add(node.metadata.assetId); } + for (const clip of proj.timeline?.clips || []) { + if (clip.directMedia?.assetId) activeAssetIds.add(clip.directMedia.assetId); + } } let assetChanged = false; // 1. 已存在的关联素材标记为 archived @@ -314,6 +322,9 @@ async function drainRemoteUserDataChanges() { } async function saveRemoteUserDataBatch(uploaded: Map) { + // 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。 + // 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。 + repairMissingCanvasAssets(); const currentProjects = useCanvasStore.getState().projects; const currentAssets = useAssetStore.getState().assets; const dirtyProjects = currentProjects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project)); @@ -326,6 +337,13 @@ async function saveRemoteUserDataBatch(uploaded: Map) { // 转换后的 resource: 引用只属于发往服务端的 payload,不能反写整份实时 store。 // 已确认快照记录的是本次上传所依据的本地实体;上传期间的新编辑会在下一轮继续提交。 + // 素材先于画布提交。这样画布中的 resource: 引用一旦成为远端事实, + // 对应 Asset 已经存在,刷新或换设备不会出现只占容量、不见素材的窗口。 + for (const source of dirtyAssets) { + const remotePayload = await ensureRemoteResourceReferences(assetForRemoteSync(source), uploaded); + await upsertRemoteAsset(remotePayload); + acknowledgedAssets.set(source.id, source); + } for (const source of dirtyProjects) { const keysToUpload = collectLocalMediaKeys(source); const total = keysToUpload.length; @@ -364,11 +382,6 @@ async function saveRemoteUserDataBatch(uploaded: Map) { throw error; } } - for (const source of dirtyAssets) { - const remotePayload = await ensureRemoteResourceReferences(assetForRemoteSync(source), uploaded); - await upsertRemoteAsset(remotePayload); - acknowledgedAssets.set(source.id, source); - } for (const id of deletedProjectIds) { await deleteRemoteCanvasProject(id); acknowledgedProjects.delete(id); diff --git a/web/src/types/timeline.ts b/web/src/types/timeline.ts index f51b54183..d59e93db3 100644 --- a/web/src/types/timeline.ts +++ b/web/src/types/timeline.ts @@ -68,6 +68,8 @@ export type TimelineClipKind = "video" | "audio" | "subtitle" | "text" | "image" export type TimelineDirectMedia = { /** 源素材 id(本地资产 id / 上传文件 id),用于生成时间线内唯一 nodeId 前缀 */ id: string; + /** 远端素材库记录;本地上传成功后必须存在。 */ + assetId?: string; kind: "video" | "audio" | "image" | "text"; title: string; storageKey?: string; diff --git a/web/test/user-scoped-generation-persistence.test.ts b/web/test/user-scoped-generation-persistence.test.ts index 0232432e2..2c5f1ea88 100644 --- a/web/test/user-scoped-generation-persistence.test.ts +++ b/web/test/user-scoped-generation-persistence.test.ts @@ -18,6 +18,7 @@ import { deleteAssetWithRemoteSync, deleteCanvasProjectsWithRemoteSync, installR import { apiClient } from "../src/services/api/request"; import { useUserStore } from "../src/stores/use-user-store"; import { CANVAS_HISTORY_STORE_KEY, useCanvasHistoryStore } from "../src/stores/canvas/use-canvas-history-store"; +import { repairMissingCanvasAssets } from "../src/services/canvas-asset-repair"; test("creation recovery observes streaming text tasks after reload", () => { const conversations = [ @@ -4535,3 +4536,124 @@ test("deleted canvas history rehydrates from the active account scope only", asy else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); } }); + +test("canvas asset repair backfills node and timeline media with one shared asset", () => { + const previousProjects = useCanvasStore.getState().projects; + const previousAssets = useAssetStore.getState().assets; + const storageKey = "resource:historical-canvas-media"; + const project: CanvasProject = { + ...storedCanvasProject("canvas-needs-asset-repair", "历史画布"), + nodes: [ + { + id: "image-node", + type: CanvasNodeType.Image, + title: "历史图片", + position: { x: 0, y: 0 }, + width: 320, + height: 180, + metadata: { content: "/api/resources/historical-canvas-media/file", storageKey }, + }, + ], + timeline: { + version: 2, + tracks: [], + clips: [ + { + id: "timeline-clip", + kind: "image", + nodeId: "timeline-image", + trackId: "track-1", + startMs: 0, + durationMs: 1000, + directMedia: { id: "timeline-image", kind: "image", title: "时间线图片", storageKey, url: "/api/resources/historical-canvas-media/file" }, + }, + ], + durationMs: 1000, + }, + }; + + try { + useAssetStore.getState().replaceAssets([]); + withCanvasStorePersistenceSuppressed(() => useCanvasStore.setState({ projects: [project] })); + const result = withCanvasStorePersistenceSuppressed(() => repairMissingCanvasAssets()); + const repaired = useCanvasStore.getState().projects[0]; + const nodeAssetId = repaired?.nodes[0]?.metadata?.assetId; + const timelineAssetId = repaired?.timeline?.clips[0]?.directMedia?.assetId; + + expect(result).toEqual({ createdAssets: 1, updatedProjects: 1 }); + expect(useAssetStore.getState().assets).toHaveLength(1); + expect(nodeAssetId).toBeTruthy(); + expect(timelineAssetId).toBe(nodeAssetId); + expect(useAssetStore.getState().assets[0]?.kind).toBe("image"); + expect(useAssetStore.getState().assets[0]?.data.storageKey).toBe(storageKey); + } finally { + useAssetStore.getState().replaceAssets(previousAssets); + withCanvasStorePersistenceSuppressed(() => useCanvasStore.setState({ projects: previousProjects })); + } +}); + +test("login repair persists the missing asset before its canvas", 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 previousAssets = useAssetStore.getState().assets; + const writes: Array<{ kind: "asset" | "canvas"; body: Record }> = []; + const project: CanvasProject = { + ...storedCanvasProject("canvas-remote-ghost", "待修复画布"), + nodes: [ + { + id: "remote-image", + type: CanvasNodeType.Image, + title: "远端图片", + position: { x: 0, y: 0 }, + width: 320, + height: 180, + metadata: { content: "/api/resources/remote-ghost/file", storageKey: "resource:remote-ghost" }, + }, + ], + }; + + 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; + apiClient.defaults.adapter = async (config) => { + const url = String(config.url || ""); + const method = String(config.method || "get").toLowerCase(); + if (url.includes("user-data/snapshot")) { + return { data: { code: 0, data: { projects: [project], assets: [] }, msg: "" }, status: 200, statusText: "OK", headers: {}, config }; + } + const body = (typeof config.data === "string" ? JSON.parse(config.data) : config.data) as Record; + if (method === "put" && url.startsWith("/assets/")) writes.push({ kind: "asset", body }); + else if (method === "put" && url.startsWith("/canvas-projects/")) writes.push({ kind: "canvas", body }); + else throw new Error(`unexpected request: ${method} ${url}`); + return { data: { code: 0, data: {}, msg: "" }, status: 200, statusText: "OK", headers: {}, config }; + }; + + try { + resetRemoteUserDataSync(); + await syncRemoteUserData("account-repair-order"); + expect(writes.map((item) => item.kind)).toEqual(["asset", "canvas"]); + const asset = writes[0]?.body.asset as Asset; + const canvas = writes[1]?.body.project as CanvasProject; + expect(asset.data.storageKey).toBe("resource:remote-ghost"); + expect(canvas.nodes[0]?.metadata?.assetId).toBe(asset.id); + } finally { + resetRemoteUserDataSync(); + useAssetStore.getState().replaceAssets(previousAssets); + withCanvasStorePersistenceSuppressed(() => 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 }); + } +});