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

450 lines
14 KiB
Go

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, false)
})
}
func (s *Service) PurgeUserAsset(userID string, id string) error {
return s.PurgeUserAssets(userID, []string{id})
}
func (s *Service) PurgeUserAssets(userID string, ids []string) error {
return s.host.WithStorageLock(func() error {
return s.host.PurgeUserAssetsWithResources(userID, ids)
})
}
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
}