223 lines
7.6 KiB
Go
223 lines
7.6 KiB
Go
package repository
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"time"
|
|
|
|
"yingce/backend/internal/assets"
|
|
"yingce/backend/internal/model"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
var ErrCanvasHistoryResourceMissing = errors.New("画布历史引用的资源不存在")
|
|
var ErrCanvasHistoryResourceReferenced = errors.New("资源仍被画布历史引用,无法删除")
|
|
|
|
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) {
|
|
var project model.CanvasProject
|
|
err := r.db.Omit("payload_json").Where("id = ? AND user_id = ?", id, userID).First(&project).Error
|
|
return &project, err
|
|
}
|
|
|
|
func (r *Repository) CanvasSnapshots(userID, canvasID string, limit int) ([]model.CanvasSnapshot, error) {
|
|
items := []model.CanvasSnapshot{}
|
|
err := r.db.Select(canvasSnapshotSummaryColumns).Where("user_id = ? AND canvas_id = ?", userID, canvasID).
|
|
Order("revision DESC").Limit(limit).Find(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
func (r *Repository) CanvasSnapshot(userID, canvasID, id string) (*model.CanvasSnapshot, error) {
|
|
var item model.CanvasSnapshot
|
|
err := r.db.Where("id = ? AND user_id = ? AND canvas_id = ?", id, userID, canvasID).First(&item).Error
|
|
return &item, err
|
|
}
|
|
|
|
// 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, 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
|
|
}
|
|
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)))
|
|
}
|
|
requiredIDs := append([]string{}, currentResourceIDs...)
|
|
if capture {
|
|
requiredIDs = append(requiredIDs, resourceIDs...)
|
|
}
|
|
slices.Sort(requiredIDs)
|
|
requiredIDs = slices.Compact(requiredIDs)
|
|
if len(requiredIDs) > 0 {
|
|
var resources []model.Resource
|
|
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
|
|
}
|
|
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 {
|
|
return nil
|
|
}
|
|
if err := tx.Create(snapshot).Error; err != nil {
|
|
return err
|
|
}
|
|
refs := make([]model.CanvasSnapshotResource, 0, len(resourceIDs))
|
|
for _, id := range resourceIDs {
|
|
refs = append(refs, model.CanvasSnapshotResource{SnapshotID: snapshot.ID, ResourceID: id})
|
|
}
|
|
if len(refs) > 0 {
|
|
if err := tx.CreateInBatches(refs, 200).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var expired []string
|
|
if err := tx.Model(&model.CanvasSnapshot{}).Where("canvas_id = ?", project.ID).Order("revision DESC").Offset(limit).Limit(100).Pluck("id", &expired).Error; err != nil {
|
|
return err
|
|
}
|
|
return deleteCanvasSnapshots(tx, expired)
|
|
})
|
|
if err == nil {
|
|
*project = next
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (r *Repository) CanvasHistoryReferencesObject(resource *model.Resource) (bool, error) {
|
|
var count int64
|
|
aliases := r.db.Model(&model.Resource{}).Select("id").Where("endpoint = ? AND bucket = ? AND object_key = ?", resource.Endpoint, resource.Bucket, resource.ObjectKey)
|
|
err := r.db.Model(&model.CanvasSnapshotResource{}).
|
|
Where("resource_id = ? OR resource_id IN (?)", resource.ID, aliases).
|
|
Count(&count).Error
|
|
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
|
|
}
|
|
if err := tx.Where("snapshot_id IN ?", ids).Delete(&model.CanvasSnapshotResource{}).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Where("id IN ?", ids).Delete(&model.CanvasSnapshot{}).Error
|
|
}
|
|
|
|
func (r *Repository) CanvasHistoryResourceReferences(resourceIDs []string) ([]ResourceDirectReference, error) {
|
|
refs := []ResourceDirectReference{}
|
|
if len(resourceIDs) == 0 {
|
|
return refs, nil
|
|
}
|
|
err := r.db.Table("canvas_snapshot_resources AS refs").
|
|
Select("'画布历史版本' AS kind, snapshots.canvas_id AS id, snapshots.title, refs.resource_id").
|
|
Joins("JOIN canvas_snapshots AS snapshots ON snapshots.id = refs.snapshot_id").
|
|
Where("refs.resource_id IN ?", resourceIDs).Distinct().Scan(&refs).Error
|
|
return refs, err
|
|
}
|
|
|
|
func (r *Repository) RequireNoCanvasHistoryReferences(resourceIDs []string) error {
|
|
if len(resourceIDs) == 0 {
|
|
return nil
|
|
}
|
|
var resources []model.Resource
|
|
query := r.db.Select("id").Where("id IN ?", resourceIDs).Order("id")
|
|
if r.Dialect() == "postgres" {
|
|
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
|
}
|
|
if err := query.Find(&resources).Error; err != nil {
|
|
return err
|
|
}
|
|
refs, err := r.CanvasHistoryResourceReferences(resourceIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(refs) > 0 {
|
|
return ErrCanvasHistoryResourceReferenced
|
|
}
|
|
return nil
|
|
}
|