fix(canvas): 资源引用 - 校验递归素材并修复断链
This commit is contained in:
1 parent
5bbd208bef
commit
117fa7d70b
24 files changed
+697
-218
No files matched your search
@@ -2,9 +2,11 @@ package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/assets"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -14,6 +16,19 @@ import (
|
||||
var ErrCanvasHistoryResourceMissing = errors.New("canvas history resource missing")
|
||||
var ErrCanvasHistoryResourceReferenced = errors.New("resource referenced by canvas history")
|
||||
|
||||
type CanvasHistoryResourceMissingError struct {
|
||||
ResourceIDs []string
|
||||
References []assets.DocumentResourceReference
|
||||
}
|
||||
|
||||
func (e *CanvasHistoryResourceMissingError) Error() string {
|
||||
return fmt.Sprintf("canvas history resources missing: %v", e.ResourceIDs)
|
||||
}
|
||||
|
||||
func (e *CanvasHistoryResourceMissingError) Unwrap() error {
|
||||
return ErrCanvasHistoryResourceMissing
|
||||
}
|
||||
|
||||
const canvasSnapshotSummaryColumns = "id, canvas_id, user_id, revision, title, node_count, connection_count, payload_bytes, reason, content_updated_at, created_at"
|
||||
|
||||
func (r *Repository) CanvasProjectMetadata(userID, id string) (*model.CanvasProject, error) {
|
||||
@@ -37,22 +52,22 @@ func (r *Repository) CanvasSnapshot(userID, canvasID, id string) (*model.CanvasS
|
||||
|
||||
// The successful CAS takes the canvas row lock before inspecting the history.
|
||||
// Snapshot creation, reference protection and retention commit with the content.
|
||||
func (r *Repository) SaveCanvasWithSnapshot(project *model.CanvasProject, snapshot *model.CanvasSnapshot, resourceIDs, restoredResourceIDs []string, cutoff time.Time, limit int, force bool) error {
|
||||
func (r *Repository) SaveCanvasWithSnapshot(project *model.CanvasProject, snapshot *model.CanvasSnapshot, resourceIDs, currentResourceIDs []string, cutoff time.Time, limit int, force bool) error {
|
||||
next := *project
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := New(tx).UpsertCanvasProject(&next); err != nil {
|
||||
return err
|
||||
}
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
capture := false
|
||||
if snapshot != nil {
|
||||
var last model.CanvasSnapshot
|
||||
err := tx.Select("id", "revision", "created_at").Where("canvas_id = ?", project.ID).Order("revision DESC").First(&last).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
capture = err != nil || (last.Revision != snapshot.Revision && (force || !last.CreatedAt.After(cutoff)))
|
||||
}
|
||||
var last model.CanvasSnapshot
|
||||
err := tx.Select("id", "revision", "created_at").Where("canvas_id = ?", project.ID).Order("revision DESC").First(&last).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
capture := err != nil || (last.Revision != snapshot.Revision && (force || !last.CreatedAt.After(cutoff)))
|
||||
requiredIDs := append([]string{}, restoredResourceIDs...)
|
||||
requiredIDs := append([]string{}, currentResourceIDs...)
|
||||
if capture {
|
||||
requiredIDs = append(requiredIDs, resourceIDs...)
|
||||
}
|
||||
@@ -60,15 +75,61 @@ func (r *Repository) SaveCanvasWithSnapshot(project *model.CanvasProject, snapsh
|
||||
requiredIDs = slices.Compact(requiredIDs)
|
||||
if len(requiredIDs) > 0 {
|
||||
var resources []model.Resource
|
||||
query := tx.Select("id").Where("id IN ? AND status = ?", requiredIDs, model.ResourceStatusReady).Order("id")
|
||||
query := tx.Select("id").Where("id IN ? AND user_id = ? AND status = ?", requiredIDs, project.UserID, model.ResourceStatusReady).Order("id")
|
||||
if r.Dialect() == "postgres" {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
if err := query.Find(&resources).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(resources) != len(requiredIDs) {
|
||||
return ErrCanvasHistoryResourceMissing
|
||||
found := make(map[string]struct{}, len(resources))
|
||||
for _, resource := range resources {
|
||||
found[resource.ID] = struct{}{}
|
||||
}
|
||||
missingIDs := make([]string, 0)
|
||||
allowDamagedPreimage := snapshot != nil && (snapshot.Reason == "before_resource_repair" || snapshot.Reason == "before_restore")
|
||||
for _, id := range requiredIDs {
|
||||
if _, exists := found[id]; !exists {
|
||||
if allowDamagedPreimage && !slices.Contains(currentResourceIDs, id) {
|
||||
continue
|
||||
}
|
||||
missingIDs = append(missingIDs, id)
|
||||
}
|
||||
}
|
||||
if len(missingIDs) > 0 {
|
||||
missingSet := make(map[string]struct{}, len(missingIDs))
|
||||
for _, id := range missingIDs {
|
||||
missingSet[id] = struct{}{}
|
||||
}
|
||||
missingRefs := make([]assets.DocumentResourceReference, 0)
|
||||
documents := map[string]string{"current": project.PayloadJSON}
|
||||
if capture {
|
||||
documents["history"] = snapshot.PayloadJSON
|
||||
}
|
||||
for _, source := range []string{"current", "history"} {
|
||||
refs, collectErr := assets.CollectDocumentResourceReferences(documents[source])
|
||||
if collectErr != nil {
|
||||
return collectErr
|
||||
}
|
||||
for _, ref := range refs {
|
||||
if _, missing := missingSet[ref.ResourceID]; missing {
|
||||
ref.Source = source
|
||||
missingRefs = append(missingRefs, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, id := range missingIDs {
|
||||
if !containsResourceReference(missingRefs, id) {
|
||||
missingRefs = append(missingRefs, assets.DocumentResourceReference{ResourceID: id, Path: "history", ReferenceType: "snapshot"})
|
||||
}
|
||||
}
|
||||
return &CanvasHistoryResourceMissingError{ResourceIDs: missingIDs, References: missingRefs}
|
||||
}
|
||||
if allowDamagedPreimage {
|
||||
resourceIDs = slices.DeleteFunc(slices.Clone(resourceIDs), func(id string) bool {
|
||||
_, exists := found[id]
|
||||
return !exists
|
||||
})
|
||||
}
|
||||
}
|
||||
if !capture {
|
||||
@@ -107,6 +168,15 @@ func (r *Repository) CanvasHistoryReferencesObject(resource *model.Resource) (bo
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func containsResourceReference(references []assets.DocumentResourceReference, resourceID string) bool {
|
||||
for _, reference := range references {
|
||||
if reference.ResourceID == resourceID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func deleteCanvasSnapshots(tx *gorm.DB, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -119,6 +119,10 @@ func (r *Repository) ResourceStorageReferenceCount(resource *model.Resource, exc
|
||||
}
|
||||
|
||||
func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID string, resourceIDs []string) (ResourceReferenceSnapshot, error) {
|
||||
return r.ResourceReferenceSnapshotExcludingAssets(userID, []string{excludingAssetID}, resourceIDs)
|
||||
}
|
||||
|
||||
func (r *Repository) ResourceReferenceSnapshotExcludingAssets(userID string, excludingAssetIDs []string, resourceIDs []string) (ResourceReferenceSnapshot, error) {
|
||||
snapshot := ResourceReferenceSnapshot{Documents: []ResourceReferenceDocument{}, Direct: []ResourceDirectReference{}}
|
||||
if len(resourceIDs) == 0 {
|
||||
return snapshot, nil
|
||||
@@ -154,7 +158,10 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s
|
||||
snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "工具", ID: strconv.FormatInt(tool.ID, 10), Title: tool.Label, PrimaryJSON: string(payload)})
|
||||
}
|
||||
var assets []model.Asset
|
||||
assetQuery := r.db.Where("user_id = ? AND id <> ?", userID, excludingAssetID)
|
||||
assetQuery := r.db.Where("user_id = ?", userID)
|
||||
if len(excludingAssetIDs) > 0 {
|
||||
assetQuery = assetQuery.Where("id NOT IN ?", excludingAssetIDs)
|
||||
}
|
||||
if err := assetQuery.Find(&assets).Error; err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
@@ -240,7 +247,10 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s
|
||||
versionQuery := r.db.Table("asset_versions").
|
||||
Select("asset_versions.id, assets.title, asset_versions.definition_json AS primary_json").
|
||||
Joins("JOIN assets ON assets.id = asset_versions.asset_id").
|
||||
Where("assets.user_id = ? AND assets.id <> ?", userID, excludingAssetID)
|
||||
Where("assets.user_id = ?", userID)
|
||||
if len(excludingAssetIDs) > 0 {
|
||||
versionQuery = versionQuery.Where("assets.id NOT IN ?", excludingAssetIDs)
|
||||
}
|
||||
if err := versionQuery.Scan(&versions).Error; err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
@@ -292,12 +302,15 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s
|
||||
ResourceID string
|
||||
}
|
||||
var representations []joinedRepresentation
|
||||
if err := r.db.Table("asset_representations").
|
||||
representationQuery := r.db.Table("asset_representations").
|
||||
Select("asset_representations.id, assets.title, asset_representations.resource_id").
|
||||
Joins("JOIN asset_versions ON asset_versions.id = asset_representations.asset_version_id").
|
||||
Joins("JOIN assets ON assets.id = asset_versions.asset_id").
|
||||
Where("assets.user_id = ? AND assets.id <> ? AND asset_representations.resource_id IN ?", userID, excludingAssetID, resourceIDs).
|
||||
Scan(&representations).Error; err != nil {
|
||||
Where("assets.user_id = ? AND asset_representations.resource_id IN ?", userID, resourceIDs)
|
||||
if len(excludingAssetIDs) > 0 {
|
||||
representationQuery = representationQuery.Where("assets.id NOT IN ?", excludingAssetIDs)
|
||||
}
|
||||
if err := representationQuery.Scan(&representations).Error; err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
for _, representation := range representations {
|
||||
@@ -403,23 +416,9 @@ func (r *Repository) DeleteAssetsAndResources(userID string, assetIDs []string,
|
||||
if len(assetIDs) == 0 || len(ownedAssets) != len(assetIDs) {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
if deleteReferencedResources {
|
||||
if len(resourceIDs) > 0 {
|
||||
// 与历史快照写入串行化,避免清除索引后又插入外键引用。
|
||||
if r.Dialect() == "postgres" {
|
||||
var resources []model.Resource
|
||||
if err := tx.Select("id").Where("id IN ?", resourceIDs).Order("id").Clauses(clause.Locking{Strength: "UPDATE"}).Find(&resources).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Where("resource_id IN ?", resourceIDs).Delete(&model.CanvasSnapshotResource{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := New(tx).RequireNoCanvasHistoryReferences(resourceIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
// Explicit deletion and archive expiry must never invalidate a snapshot.
|
||||
if err := New(tx).RequireNoCanvasHistoryReferences(resourceIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id IN ?", assetIDs)
|
||||
if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.ShotAssetReference{}).Error; err != nil {
|
||||
|
||||
Reference in new issue
Block a user