186 lines
6.3 KiB
Go
186 lines
6.3 KiB
Go
package canvas
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"infinite-canvas/backend/internal/assets"
|
|
"infinite-canvas/backend/internal/kernel"
|
|
"infinite-canvas/backend/internal/model"
|
|
"infinite-canvas/backend/internal/repository"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const canvasHistoryLimit = 20
|
|
const canvasHistoryInterval = 5 * time.Minute
|
|
|
|
// 浏览器、云端 Agent 和创作任务共用此入口;外层事务仍负责任务和画布的一致性。
|
|
func SaveDocumentWithHistory(repo *repository.Repository, before *model.CanvasProject, after *model.CanvasProject, reason string) error {
|
|
snapshot, resourceIDs, err := buildCanvasSnapshot(before, *after, reason)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var restoredIDs []string
|
|
if reason == "before_restore" {
|
|
refs := map[string]struct{}{}
|
|
if err := assets.CollectOwnedDocumentReferences(after.PayloadJSON, refs); err != nil {
|
|
return err
|
|
}
|
|
restoredIDs = assets.SortedIDs(refs)
|
|
}
|
|
return repo.SaveCanvasWithSnapshot(after, snapshot, resourceIDs, restoredIDs, after.UpdatedAt.Add(-canvasHistoryInterval), canvasHistoryLimit, reason == "before_restore")
|
|
}
|
|
|
|
func canvasRevisionConflict() error {
|
|
return kernel.NewAppError(http.StatusConflict, "云端画布已有更新,已停止覆盖;请保留本地草稿并加载最新版本")
|
|
}
|
|
|
|
type CanvasHistoryList struct {
|
|
Snapshots []model.CanvasSnapshot `json:"snapshots"`
|
|
CurrentRevision int64 `json:"currentRevision"`
|
|
}
|
|
|
|
func (s *Service) scopedCanvasHistoryProject(userID string, canvasID string) (*model.CanvasProject, error) {
|
|
project, err := s.repo.CanvasProjectMetadata(userID, strings.TrimSpace(canvasID))
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, kernel.NewAppError(http.StatusNotFound, "画布不存在或无权访问")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return project, nil
|
|
}
|
|
|
|
func (s *Service) CanvasHistory(userID string, canvasID string) (CanvasHistoryList, error) {
|
|
project, err := s.scopedCanvasHistoryProject(userID, canvasID)
|
|
if err != nil {
|
|
return CanvasHistoryList{}, err
|
|
}
|
|
items, err := s.repo.CanvasSnapshots(project.UserID, project.ID, canvasHistoryLimit)
|
|
return CanvasHistoryList{Snapshots: items, CurrentRevision: project.Revision}, err
|
|
}
|
|
|
|
func (s *Service) CanvasHistorySnapshot(userID string, canvasID, snapshotID string) (*model.CanvasSnapshot, error) {
|
|
project, err := s.scopedCanvasHistoryProject(userID, canvasID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
item, err := s.repo.CanvasSnapshot(project.UserID, project.ID, snapshotID)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, kernel.NewAppError(http.StatusNotFound, "历史版本不存在或已过期,请刷新历史列表")
|
|
}
|
|
return item, err
|
|
}
|
|
|
|
func (s *Service) RestoreCanvasHistory(userID string, canvasID, snapshotID string, revision *int64) (UserDataSummary, error) {
|
|
if revision == nil {
|
|
return UserDataSummary{}, kernel.NewAppError(http.StatusPreconditionRequired, "请先刷新画布版本再恢复")
|
|
}
|
|
current, err := s.scopedCanvasHistoryProject(userID, canvasID)
|
|
if err != nil {
|
|
return UserDataSummary{}, err
|
|
}
|
|
if current.Revision != *revision {
|
|
return UserDataSummary{}, canvasRevisionConflict()
|
|
}
|
|
item, err := s.CanvasHistorySnapshot(userID, canvasID, snapshotID)
|
|
if err != nil {
|
|
return UserDataSummary{}, err
|
|
}
|
|
var payload map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(item.PayloadJSON), &payload); err != nil {
|
|
return UserDataSummary{}, err
|
|
}
|
|
// Restore document content while retaining its present ownership and business association.
|
|
for key, value := range map[string]any{
|
|
"id": current.ID, "revision": *revision, "createdAt": current.CreatedAt,
|
|
"projectId": current.ProjectID,
|
|
} {
|
|
payload[key], _ = json.Marshal(value)
|
|
}
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return UserDataSummary{}, err
|
|
}
|
|
return s.upsertUserCanvasProjectWithHistory(userID, raw, "before_restore")
|
|
}
|
|
|
|
func canvasHistoryContent(project model.CanvasProject) ([]byte, error) {
|
|
raw, err := canvasProjectPayload(project)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var payload map[string]any
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&payload); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, key := range []string{"revision", "updatedAt", "createdAt", "viewport", "remoteContentHash"} {
|
|
delete(payload, key)
|
|
}
|
|
return json.Marshal(payload)
|
|
}
|
|
|
|
func buildCanvasSnapshot(before *model.CanvasProject, after model.CanvasProject, reason string) (*model.CanvasSnapshot, []string, error) {
|
|
if before == nil {
|
|
return nil, nil, nil
|
|
}
|
|
oldContent, err := canvasHistoryContent(*before)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
newContent, err := canvasHistoryContent(after)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if reason != "before_restore" && bytes.Equal(oldContent, newContent) {
|
|
return nil, nil, nil
|
|
}
|
|
raw, err := canvasProjectPayload(*before)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var counts struct {
|
|
Nodes []json.RawMessage
|
|
Connections []json.RawMessage
|
|
}
|
|
if err := json.Unmarshal(raw, &counts); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
resources := map[string]struct{}{}
|
|
if err := assets.CollectOwnedDocumentReferences(string(raw), resources); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &model.CanvasSnapshot{
|
|
ID: kernel.NewID(), CanvasID: before.ID, UserID: before.UserID, Revision: before.Revision,
|
|
Title: before.Title, NodeCount: len(counts.Nodes), ConnectionCount: len(counts.Connections),
|
|
PayloadJSON: string(raw), PayloadBytes: len(raw), Reason: reason,
|
|
ContentUpdatedAt: before.UpdatedAt, CreatedAt: after.UpdatedAt,
|
|
}, assets.SortedIDs(resources), nil
|
|
}
|
|
|
|
// Row metadata is authoritative, including changes made by project association operations.
|
|
func canvasProjectPayload(project model.CanvasProject) (json.RawMessage, error) {
|
|
var payload map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(project.PayloadJSON), &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
if payload == nil {
|
|
return nil, kernel.BadAuthRequest("画布数据格式错误")
|
|
}
|
|
for key, value := range map[string]any{"id": project.ID, "title": project.Title, "projectId": project.ProjectID, "revision": project.Revision, "createdAt": project.CreatedAt, "updatedAt": project.UpdatedAt} {
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
payload[key] = encoded
|
|
}
|
|
return json.Marshal(payload)
|
|
}
|