Files
ttstd-infinite-canvas/backend/internal/canvas/user_data.go

440 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package canvas
import (
"encoding/json"
"errors"
"infinite-canvas/backend/internal/kernel"
"net/http"
"strings"
"time"
"unicode/utf8"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"gorm.io/gorm"
)
type AssetsSyncRequest struct {
Assets []json.RawMessage `json:"assets"`
}
type UserDataSummary struct {
ID string `json:"id"`
FolderID string `json:"folderId,omitempty"`
Kind string `json:"kind,omitempty"`
Category string `json:"category,omitempty"`
Status string `json:"status,omitempty"`
Title string `json:"title"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Revision int64 `json:"revision,omitempty"`
SaveAudit *CanvasSaveAudit `json:"-"`
}
type CanvasSaveAudit struct {
NodesBefore int
NodesAfter int
}
func canvasNodeCount(payload string) int {
var document struct {
Nodes []json.RawMessage `json:"nodes"`
}
_ = json.Unmarshal([]byte(payload), &document)
return len(document.Nodes)
}
type UserDataSnapshot struct {
Assets []json.RawMessage `json:"assets"`
Projects []json.RawMessage `json:"projects"`
}
func (s *Service) UserDataSnapshot(userID string) (UserDataSnapshot, error) {
assets, err := s.UserAssets(userID)
if err != nil {
return UserDataSnapshot{}, err
}
projects, err := s.UserCanvasProjects(userID)
if err != nil {
return UserDataSnapshot{}, err
}
return UserDataSnapshot{Assets: assets, Projects: projects}, nil
}
func (s *Service) UserAssetSummaries(userID string) ([]UserDataSummary, error) {
assets, err := s.repo.AssetSummaries(userID)
if err != nil {
return nil, err
}
result := make([]UserDataSummary, 0, len(assets))
for _, asset := range assets {
result = append(result, UserDataSummary{ID: asset.ID, FolderID: asset.FolderID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt})
}
return result, nil
}
func (s *Service) UserAsset(userID string, id string) (json.RawMessage, error) {
asset, err := s.repo.AssetForUser(userID, id)
if err != nil {
return nil, err
}
return ClientAssetPayload(*asset), nil
}
func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataSummary, error) {
asset, err := AssetFromJSON(userID, raw)
if err != nil {
return UserDataSummary{}, err
}
err = s.host.WithStorageLock(func() error {
if asset.FolderID != "" {
if _, err := s.repo.AssetFolderForUser(userID, asset.FolderID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return kernel.BadAuthRequest("素材分类不存在")
}
return err
}
}
existing, existingErr := s.repo.AssetForUser(userID, asset.ID)
if existingErr != nil && !errors.Is(existingErr, gorm.ErrRecordNotFound) {
return existingErr
}
if existing != nil && existing.PayloadJSON != asset.PayloadJSON {
if err := s.ValidateAssetCanvasReferences(userID, asset); err != nil {
return err
}
}
existingBytes := int64(0)
if existing != nil {
existingBytes = int64(len([]byte(existing.PayloadJSON)))
}
if err := s.host.StructuredQuota(userID, "asset", errors.Is(existingErr, gorm.ErrRecordNotFound), int64(len(raw))-existingBytes); err != nil {
return err
}
if err := s.repo.UpsertAsset(&asset); err != nil {
return err
}
if existingErr != nil {
s.host.RecordActivity(userID, "asset", 1)
}
return nil
})
if err != nil {
return UserDataSummary{}, err
}
return UserDataSummary{ID: asset.ID, FolderID: asset.FolderID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt}, nil
}
func (s *Service) DeleteUserAsset(userID string, id string) error {
return s.host.WithStorageLock(func() error {
return s.host.DeleteUserAssetWithResources(userID, id)
})
}
func (s *Service) UserAssets(userID string) ([]json.RawMessage, error) {
assets, err := s.repo.Assets(userID)
if err != nil {
return nil, err
}
result := make([]json.RawMessage, 0, len(assets))
for _, asset := range assets {
if payload := ClientAssetPayload(asset); len(payload) > 0 {
result = append(result, payload)
}
}
return result, nil
}
func (s *Service) ReplaceUserAssets(userID string, req AssetsSyncRequest) ([]json.RawMessage, error) {
assets := make([]model.Asset, 0, len(req.Assets))
var totalBytes int64
for _, raw := range req.Assets {
item, err := AssetFromJSON(userID, raw)
if err != nil {
return nil, err
}
assets = append(assets, item)
totalBytes += int64(len(raw))
}
err := s.host.WithStorageLock(func() error {
if err := s.ValidateAssetReplacementCanvasReferences(userID, assets); err != nil {
return err
}
if err := s.host.StructuredReplacementQuota(userID, "asset", len(assets), totalBytes); err != nil {
return err
}
if err := s.repo.ReplaceAssets(userID, assets); err != nil {
return err
}
if len(assets) > 0 {
s.host.RecordActivity(userID, "asset", len(assets))
}
return nil
})
if err != nil {
return nil, err
}
return s.UserAssets(userID)
}
func (s *Service) UserCanvasProjects(userID string) ([]json.RawMessage, error) {
projects, err := s.repo.CanvasProjects(userID)
if err != nil {
return nil, err
}
result := make([]json.RawMessage, 0, len(projects))
for _, project := range projects {
if strings.TrimSpace(project.PayloadJSON) != "" {
payload, err := canvasProjectPayload(project)
if err != nil {
return nil, err
}
result = append(result, payload)
}
}
return result, nil
}
func (s *Service) UserCanvasProjectSummaries(userID string) ([]UserDataSummary, error) {
projects, err := s.repo.CanvasProjectSummaries(userID)
if err != nil {
return nil, err
}
result := make([]UserDataSummary, 0, len(projects))
for _, project := range projects {
result = append(result, UserDataSummary{ID: project.ID, Title: project.Title, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, Revision: project.Revision})
}
return result, nil
}
func (s *Service) UserCanvasProject(userID string, id string) (json.RawMessage, error) {
project, err := s.repo.CanvasProjectForUser(userID, id)
if err != nil {
return nil, err
}
return canvasProjectPayload(*project)
}
func (s *Service) UpsertUserCanvasProject(userID string, raw json.RawMessage) (UserDataSummary, error) {
return s.upsertUserCanvasProjectWithHistory(userID, raw, "automatic")
}
func (s *Service) upsertUserCanvasProjectWithHistory(userID string, raw json.RawMessage, reason string) (UserDataSummary, error) {
var version struct {
Revision *int64 `json:"revision"`
}
if err := json.Unmarshal(raw, &version); err != nil {
return UserDataSummary{}, kernel.BadAuthRequest("画布版本格式错误")
}
if version.Revision == nil {
return UserDataSummary{}, kernel.NewAppError(http.StatusPreconditionRequired, "缺少画布版本,请保留本地草稿后重新加载画布")
}
if *version.Revision < 0 || *version.Revision >= 9007199254740991 {
return UserDataSummary{}, kernel.BadAuthRequest("画布版本无效")
}
project, err := canvasProjectFromJSON(userID, raw)
if err != nil {
return UserDataSummary{}, err
}
var audit CanvasSaveAudit
err = s.host.WithStorageLock(func() error {
if err := s.ValidateCanvasMediaAssets(userID, raw); err != nil {
return err
}
existing, existingErr := s.repo.CanvasProjectForUser(userID, project.ID)
if existingErr != nil && !errors.Is(existingErr, gorm.ErrRecordNotFound) {
return existingErr
}
existingBytes := int64(0)
project.Revision = *version.Revision
project.UpdatedAt = time.Now().UTC()
if existing != nil {
existingBytes = int64(len([]byte(existing.PayloadJSON)))
project.CreatedAt = existing.CreatedAt
}
if (existing == nil && project.Revision != 0) || (existing != nil && project.Revision != existing.Revision) {
return canvasRevisionConflict()
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(raw, &payload); err != nil {
return err
}
delete(payload, "revision")
delete(payload, "remoteContentHash")
payload["viewport"] = json.RawMessage(`{"x":0,"y":0,"k":1}`)
if existing != nil {
var previous map[string]json.RawMessage
if json.Unmarshal([]byte(existing.PayloadJSON), &previous) == nil && previous["viewport"] != nil {
payload["viewport"] = previous["viewport"]
}
}
payload["createdAt"], _ = json.Marshal(project.CreatedAt)
payload["updatedAt"], _ = json.Marshal(project.UpdatedAt)
cleaned, err := json.Marshal(payload)
if err != nil {
return err
}
project.PayloadJSON = string(cleaned)
if err := s.host.StructuredQuota(userID, "canvas", errors.Is(existingErr, gorm.ErrRecordNotFound), int64(len(cleaned))-existingBytes); err != nil {
return err
}
if err := SaveDocumentWithHistory(s.repo, existing, &project, reason); err != nil {
if errors.Is(err, repository.ErrCanvasRevisionConflict) {
return canvasRevisionConflict()
}
if errors.Is(err, repository.ErrCanvasHistoryResourceMissing) {
return kernel.NewAppError(http.StatusConflict, "画布引用的素材已变化,当前内容未被覆盖,请保留草稿并重新加载")
}
return err
}
audit.NodesAfter = canvasNodeCount(project.PayloadJSON)
if existing != nil {
audit.NodesBefore = canvasNodeCount(existing.PayloadJSON)
}
if existingErr != nil || existing.PayloadJSON != project.PayloadJSON || existing.Title != project.Title {
s.host.RecordActivity(userID, "canvas", 1)
}
return nil
})
if err != nil {
return UserDataSummary{}, err
}
return UserDataSummary{ID: project.ID, Title: project.Title, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, Revision: project.Revision, SaveAudit: &audit}, nil
}
func (s *Service) DeleteUserCanvasProject(userID string, id string) error {
return s.repo.DeleteCanvasProject(userID, id)
}
func AssetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) {
if err := ValidateSyncedPayload(raw, "素材"); err != nil {
return model.Asset{}, err
}
var payload struct {
ID string `json:"id"`
FolderID string `json:"folderId"`
Kind string `json:"kind"`
Category string `json:"category"`
Status string `json:"status"`
PrimaryVersionID string `json:"primaryVersionId"`
Title string `json:"title"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
return model.Asset{}, kernel.BadAuthRequest("素材数据格式错误")
}
now := time.Now()
createdAt := parseClientTime(payload.CreatedAt, now)
updatedAt := parseClientTime(payload.UpdatedAt, createdAt)
id := strings.TrimSpace(payload.ID)
if id == "" {
id = kernel.NewID()
}
if utf8.RuneCountInString(id) > model.AssetIDMaxLength {
return model.Asset{}, kernel.BadAuthRequest("素材 ID 不能超过 80 个字符")
}
primaryVersionID := strings.TrimSpace(payload.PrimaryVersionID)
if utf8.RuneCountInString(primaryVersionID) > 36 {
return model.Asset{}, kernel.BadAuthRequest("素材主版本 ID 不能超过 36 个字符")
}
if err := validateUserAssetDocument(raw); err != nil {
return model.Asset{}, err
}
category := model.NormalizeAssetCategory(model.AssetCategory(payload.Category), payload.Kind)
status := model.AssetVersionStatus(strings.TrimSpace(payload.Status))
if status == "" {
status = model.AssetVersionStatusConfirmed
}
return model.Asset{
ID: id,
UserID: userID,
FolderID: strings.TrimSpace(payload.FolderID),
Kind: strings.TrimSpace(payload.Kind),
Category: category,
Status: status,
PrimaryVersionID: primaryVersionID,
Title: strings.TrimSpace(payload.Title),
PayloadJSON: string(raw),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}, nil
}
func canvasProjectFromJSON(userID string, raw json.RawMessage) (model.CanvasProject, error) {
if err := ValidateSyncedPayload(raw, "画布"); err != nil {
return model.CanvasProject{}, err
}
var payload struct {
ID string `json:"id"`
Title string `json:"title"`
ProjectID string `json:"projectId"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
return model.CanvasProject{}, kernel.BadAuthRequest("画布数据格式错误")
}
now := time.Now()
createdAt := parseClientTime(payload.CreatedAt, now)
updatedAt := parseClientTime(payload.UpdatedAt, createdAt)
id := strings.TrimSpace(payload.ID)
if id == "" {
id = kernel.NewID()
}
return model.CanvasProject{
ID: id,
UserID: userID,
ProjectID: strings.TrimSpace(payload.ProjectID),
Title: strings.TrimSpace(payload.Title),
PayloadJSON: string(raw),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}, nil
}
func ValidateSyncedPayload(raw json.RawMessage, label string) error {
if len(raw) > 4<<20 {
return kernel.BadAuthRequest(label + "数据超过 4MB,请先把媒体文件保存到资源存储")
}
var payload interface{}
if err := json.Unmarshal(raw, &payload); err == nil && ContainsInlineMediaDataURL(payload) {
return kernel.BadAuthRequest(label + "数据包含内嵌媒体,请先上传到资源存储")
}
return nil
}
// 同步数据只禁止作为字段值存在的媒体 Data URL;提示词和上游错误文案可能合法提到相同字符串。
func ContainsInlineMediaDataURL(value interface{}) bool {
switch item := value.(type) {
case string:
text := strings.ToLower(strings.TrimSpace(item))
return strings.HasPrefix(text, "data:image/") || strings.HasPrefix(text, "data:video/") || strings.HasPrefix(text, "data:audio/")
case []interface{}:
for _, child := range item {
if ContainsInlineMediaDataURL(child) {
return true
}
}
case map[string]interface{}:
for _, child := range item {
if ContainsInlineMediaDataURL(child) {
return true
}
}
}
return false
}
func parseClientTime(value string, fallback time.Time) time.Time {
value = strings.TrimSpace(value)
if value == "" {
return fallback
}
if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
return parsed
}
return fallback
}